Compare commits
14 Commits
3377092a3f
...
0.28
| Author | SHA1 | Date | |
|---|---|---|---|
| ced238385e | |||
| 77743019b0 | |||
| f712198319 | |||
| 1ac5df7be5 | |||
| 109ef983f0 | |||
| 083a51023c | |||
| 54762cd421 | |||
| b2aaca5df9 | |||
| 883bc4489e | |||
| 3ad4a21f5b | |||
| 3c1604c7af | |||
| 8b3223f865 | |||
| 276e18a9c8 | |||
| 61f2126c1b |
@@ -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
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -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
|
||||||
|
|||||||
549
app.py
549
app.py
@@ -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,157 +110,52 @@ 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("/fees")
|
@app.route("/flush-cache", methods=["GET", "POST"])
|
||||||
def fees():
|
def flush_cache_endpoint():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
if request.method == "GET":
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
return render_template("flush-cache.html")
|
||||||
|
deleted = flush_cache()
|
||||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
return render_template("flush-cache.html", flushed=True, deleted=deleted)
|
||||||
record_step("fetch_members")
|
|
||||||
if not members_data:
|
|
||||||
return "No data."
|
|
||||||
members, sorted_months = members_data
|
|
||||||
|
|
||||||
# Filter to adults only for display
|
@app.route("/sync-bank")
|
||||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
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)
|
||||||
|
|
||||||
# Format month labels
|
@app.route("/version")
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
def version():
|
||||||
|
return BUILD_META
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
|
||||||
|
|
||||||
# Get exceptions for formatting
|
|
||||||
credentials_path = CREDENTIALS_PATH
|
|
||||||
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")
|
|
||||||
|
|
||||||
formatted_results = []
|
|
||||||
for name, month_fees in results:
|
|
||||||
row = {"name": name, "months": []}
|
|
||||||
norm_name = normalize(name)
|
|
||||||
for m in sorted_months:
|
|
||||||
fee, count = month_fees.get(m, (0, 0))
|
|
||||||
|
|
||||||
# Check for exception
|
|
||||||
norm_period = normalize(m)
|
|
||||||
ex_data = exceptions.get((norm_name, norm_period))
|
|
||||||
override_amount = ex_data["amount"] if ex_data else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != fee:
|
|
||||||
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
|
||||||
is_overridden = True
|
|
||||||
else:
|
|
||||||
if isinstance(fee, int):
|
|
||||||
monthly_totals[m] += fee
|
|
||||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
|
||||||
is_overridden = False
|
|
||||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"fees.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
results=formatted_results,
|
|
||||||
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
|
|
||||||
attendance_url=attendance_url,
|
|
||||||
payments_url=payments_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/fees-juniors")
|
|
||||||
def fees_juniors():
|
|
||||||
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"
|
|
||||||
|
|
||||||
members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
|
||||||
record_step("fetch_junior_members")
|
|
||||||
if not members_data:
|
|
||||||
return "No data."
|
|
||||||
members, sorted_months = members_data
|
|
||||||
|
|
||||||
# Sort members by name
|
|
||||||
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
|
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
|
||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
|
||||||
|
|
||||||
# Get exceptions for formatting (reusing payments sheet)
|
|
||||||
credentials_path = CREDENTIALS_PATH
|
|
||||||
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")
|
|
||||||
|
|
||||||
formatted_results = []
|
|
||||||
for name, month_fees in results:
|
|
||||||
row = {"name": name, "months": []}
|
|
||||||
norm_name = normalize(name)
|
|
||||||
for m in sorted_months:
|
|
||||||
fee_data = month_fees.get(m, (0, 0, 0, 0))
|
|
||||||
if len(fee_data) == 4:
|
|
||||||
fee, total_count, adult_count, junior_count = fee_data
|
|
||||||
else:
|
|
||||||
fee, total_count = fee_data
|
|
||||||
adult_count, junior_count = 0, 0
|
|
||||||
|
|
||||||
# Check for exception
|
|
||||||
norm_period = normalize(m)
|
|
||||||
ex_data = exceptions.get((norm_name, norm_period))
|
|
||||||
override_amount = ex_data["amount"] if ex_data else None
|
|
||||||
|
|
||||||
if ex_data is None and isinstance(fee, int):
|
|
||||||
monthly_totals[m] += fee
|
|
||||||
|
|
||||||
# Formulate the count string display
|
|
||||||
if adult_count > 0 and junior_count > 0:
|
|
||||||
count_str = f"{total_count} ({adult_count}A+{junior_count}J)"
|
|
||||||
elif adult_count > 0:
|
|
||||||
count_str = f"{total_count} (A)"
|
|
||||||
elif junior_count > 0:
|
|
||||||
count_str = f"{total_count} (J)"
|
|
||||||
else:
|
|
||||||
count_str = f"{total_count}"
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != fee:
|
|
||||||
cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK"
|
|
||||||
is_overridden = True
|
|
||||||
else:
|
|
||||||
if fee == "?":
|
|
||||||
cell = f"? / {count_str}" if total_count > 0 else "-"
|
|
||||||
else:
|
|
||||||
cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-"
|
|
||||||
is_overridden = False
|
|
||||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"fees-juniors.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
results=formatted_results,
|
|
||||||
totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()],
|
|
||||||
attendance_url=attendance_url,
|
|
||||||
payments_url=payments_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/adults")
|
@app.route("/adults")
|
||||||
def adults_view():
|
def adults_view():
|
||||||
@@ -279,13 +183,16 @@ def adults_view():
|
|||||||
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
current_month = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
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 = []
|
||||||
|
payable_amount = 0
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata.get("expected", 0)
|
expected = mdata.get("expected", 0)
|
||||||
@@ -293,12 +200,12 @@ def adults_view():
|
|||||||
count = mdata.get("attendance_count", 0)
|
count = mdata.get("attendance_count", 0)
|
||||||
paid = int(mdata.get("paid", 0))
|
paid = int(mdata.get("paid", 0))
|
||||||
exception_info = mdata.get("exception", None)
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
monthly_totals[m]["expected"] += expected
|
monthly_totals[m]["expected"] += expected
|
||||||
monthly_totals[m]["paid"] += paid
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
if override_amount is not None and override_amount != original_expected:
|
||||||
is_overridden = True
|
is_overridden = True
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
||||||
@@ -309,7 +216,7 @@ def adults_view():
|
|||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected > 0:
|
if expected > 0:
|
||||||
amount_to_pay = max(0, expected - paid)
|
amount_to_pay = max(0, expected - paid)
|
||||||
if paid >= expected:
|
if paid >= expected:
|
||||||
@@ -318,36 +225,55 @@ def adults_view():
|
|||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "partial"
|
status = "partial"
|
||||||
cell_text = f"{paid}/{fee_display}"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"0/{fee_display}"
|
cell_text = f"0/{fee_display}"
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
else:
|
else:
|
||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected > 0 or paid > 0:
|
if expected > 0 or paid > 0:
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
else:
|
else:
|
||||||
tooltip = ""
|
tooltip = ""
|
||||||
|
|
||||||
row["months"].append({
|
row["months"].append({
|
||||||
"text": cell_text,
|
"text": cell_text,
|
||||||
"overridden": is_overridden,
|
"overridden": is_overridden,
|
||||||
"status": status,
|
"status": status,
|
||||||
"amount": amount_to_pay,
|
"amount": amount_to_pay,
|
||||||
"month": month_labels[m],
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
"tooltip": tooltip
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
# Compute balance excluding current/future months
|
||||||
row["balance"] = data["total_balance"]
|
current_month_debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
pd = int(mdata.get("paid", 0))
|
||||||
|
current_month_debt += max(0, exp - pd)
|
||||||
|
settled_balance = data["total_balance"] + current_month_debt
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
formatted_totals = []
|
formatted_totals = []
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
t = monthly_totals[m]
|
t = monthly_totals[m]
|
||||||
@@ -359,14 +285,19 @@ def adults_view():
|
|||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
else:
|
else:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
|
|
||||||
formatted_totals.append({
|
formatted_totals.append({
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
"status": status
|
"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"])
|
def settled_balance(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"])
|
data = result["members"][name]
|
||||||
|
debt = sum(max(0, data["months"].get(m, {"expected": 0, "paid": 0}).get("expected", 0) - int(data["months"].get(m, {"expected": 0, "paid": 0}).get("paid", 0))) for m in sorted_months if m >= current_month)
|
||||||
|
return data["total_balance"] + debt
|
||||||
|
|
||||||
|
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -385,106 +316,8 @@ def adults_view():
|
|||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
current_month=current_month
|
||||||
|
|
||||||
@app.route("/reconcile")
|
|
||||||
def reconcile_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"
|
|
||||||
|
|
||||||
# Use hardcoded credentials path for now, consistent with other scripts
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
|
||||||
|
|
||||||
# Filter to adults for the main table
|
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
|
||||||
|
|
||||||
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, "paid": 0})
|
|
||||||
expected = mdata["expected"]
|
|
||||||
paid = int(mdata["paid"])
|
|
||||||
|
|
||||||
status = "empty"
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected > 0:
|
|
||||||
if paid >= expected:
|
|
||||||
status = "ok"
|
|
||||||
cell_text = "OK"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{expected}"
|
|
||||||
amount_to_pay = expected - paid
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"UNPAID {expected}"
|
|
||||||
amount_to_pay = expected
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m]
|
|
||||||
})
|
|
||||||
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
|
||||||
row["balance"] = data["total_balance"] # Updated to use total_balance
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
# Format credits and debts
|
|
||||||
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"])
|
|
||||||
# Format unmatched
|
|
||||||
unmatched = result["unmatched"]
|
|
||||||
import json
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"reconcile.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
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("/juniors")
|
@app.route("/juniors")
|
||||||
@@ -531,13 +364,16 @@ def juniors_view():
|
|||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||||
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
current_month = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
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 = []
|
||||||
|
payable_amount = 0
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata.get("expected", 0)
|
expected = mdata.get("expected", 0)
|
||||||
@@ -545,11 +381,11 @@ def juniors_view():
|
|||||||
count = mdata.get("attendance_count", 0)
|
count = mdata.get("attendance_count", 0)
|
||||||
paid = int(mdata.get("paid", 0))
|
paid = int(mdata.get("paid", 0))
|
||||||
exception_info = mdata.get("exception", None)
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
if expected != "?" and isinstance(expected, int):
|
if expected != "?" and isinstance(expected, int):
|
||||||
monthly_totals[m]["expected"] += expected
|
monthly_totals[m]["expected"] += expected
|
||||||
monthly_totals[m]["paid"] += paid
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||||
adult_count = 0
|
adult_count = 0
|
||||||
junior_count = 0
|
junior_count = 0
|
||||||
@@ -565,9 +401,9 @@ def juniors_view():
|
|||||||
breakdown = f":{adult_count}A"
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
if override_amount is not None and override_amount != original_expected:
|
||||||
is_overridden = True
|
is_overridden = True
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||||
@@ -578,11 +414,11 @@ def juniors_view():
|
|||||||
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 = f"{paid}/{fee_display}"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
@@ -590,34 +426,54 @@ def juniors_view():
|
|||||||
status = "partial"
|
status = "partial"
|
||||||
cell_text = f"{paid}/{fee_display}"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
amount_to_pay = expected - paid
|
amount_to_pay = expected - paid
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"0/{fee_display}"
|
cell_text = f"0/{fee_display}"
|
||||||
amount_to_pay = expected
|
amount_to_pay = expected
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
else:
|
else:
|
||||||
tooltip = ""
|
tooltip = ""
|
||||||
|
|
||||||
row["months"].append({
|
row["months"].append({
|
||||||
"text": cell_text,
|
"text": cell_text,
|
||||||
"overridden": is_overridden,
|
"overridden": is_overridden,
|
||||||
"status": status,
|
"status": status,
|
||||||
"amount": amount_to_pay,
|
"amount": amount_to_pay,
|
||||||
"month": month_labels[m],
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
"tooltip": tooltip
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
# Compute balance excluding current/future months
|
||||||
row["balance"] = data["total_balance"]
|
current_month_debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
pd = int(mdata.get("paid", 0))
|
||||||
|
current_month_debt += max(0, exp - pd)
|
||||||
|
settled_balance = data["total_balance"] + current_month_debt
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
formatted_totals = []
|
formatted_totals = []
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
t = monthly_totals[m]
|
t = monthly_totals[m]
|
||||||
@@ -629,15 +485,27 @@ def juniors_view():
|
|||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
else:
|
else:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
|
|
||||||
formatted_totals.append({
|
formatted_totals.append({
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
|
|
||||||
# Format credits and debts
|
# 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"])
|
def junior_settled_balance(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"])
|
data = result["members"][name]
|
||||||
|
debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
debt += max(0, exp - int(mdata.get("paid", 0)))
|
||||||
|
return data["total_balance"] + debt
|
||||||
|
|
||||||
|
junior_all_names = [name for name, _, _ in adapted_members]
|
||||||
|
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -656,121 +524,8 @@ def juniors_view():
|
|||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
current_month=current_month
|
||||||
|
|
||||||
@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}"
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Filter to juniors for the main table
|
|
||||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
|
||||||
|
|
||||||
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, "paid": 0})
|
|
||||||
expected = mdata["expected"]
|
|
||||||
paid = int(mdata["paid"])
|
|
||||||
|
|
||||||
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 = "OK"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{expected}"
|
|
||||||
amount_to_pay = expected - paid
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"UNPAID {expected}"
|
|
||||||
amount_to_pay = expected
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m]
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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"])
|
|
||||||
import json
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"reconcile-juniors.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
member_data=json.dumps(result["members"]),
|
|
||||||
month_labels_json=json.dumps(month_labels),
|
|
||||||
credits=credits,
|
|
||||||
debts=debts,
|
|
||||||
unmatched=[],
|
|
||||||
attendance_url=attendance_url,
|
|
||||||
payments_url=payments_url,
|
|
||||||
bank_account=BANK_ACCOUNT
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
52
docs/operation-manual.md
Normal file
52
docs/operation-manual.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Operation Manual
|
||||||
|
|
||||||
|
## Adding a Monthly Fee Override
|
||||||
|
|
||||||
|
Use this when the club decides to charge a different flat fee for a specific month — for example, a reduced fee during a short or holiday month.
|
||||||
|
|
||||||
|
There are two independent dictionaries in [scripts/attendance.py](../scripts/attendance.py), one for adults and one for juniors. Edit whichever tiers need an override.
|
||||||
|
|
||||||
|
### Adults
|
||||||
|
|
||||||
|
Add an entry to `ADULT_FEE_MONTHLY_RATE` (line ~15):
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
|
"2026-03": 350 # reduced fee for March 2026
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `ADULT_FEE_DEFAULT` (750 CZK) for members who attended 2+ practices that month. The single-practice fee (`ADULT_FEE_SINGLE`, 200 CZK) is unaffected.
|
||||||
|
|
||||||
|
### Juniors
|
||||||
|
|
||||||
|
Add an entry to `JUNIOR_MONTHLY_RATE` (line ~20):
|
||||||
|
|
||||||
|
```python
|
||||||
|
JUNIOR_MONTHLY_RATE = {
|
||||||
|
"2025-09": 250, # reduced fee for September 2025
|
||||||
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `JUNIOR_FEE_DEFAULT` (500 CZK) for members who attended 2+ practices that month.
|
||||||
|
|
||||||
|
### Example: March 2026
|
||||||
|
|
||||||
|
Both tiers reduced to 350 CZK (adults) and 250 CZK (juniors):
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
|
"2026-03": 350
|
||||||
|
}
|
||||||
|
|
||||||
|
JUNIOR_MONTHLY_RATE = {
|
||||||
|
"2026-03": 250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Overrides apply to all members of the given tier — use the **exceptions sheet** in Google Sheets for per-member overrides instead.
|
||||||
|
- After changing these values, restart the web dashboard (`make web`) for the change to take effect.
|
||||||
|
- The override only affects the calculated/expected fee. It does not modify any already-recorded payments in the bank sheet.
|
||||||
@@ -10,12 +10,16 @@ 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 = {
|
||||||
"2025-09": 250
|
"2025-09": 250,
|
||||||
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
}
|
}
|
||||||
ADULT_MERGED_MONTHS = {
|
ADULT_MERGED_MONTHS = {
|
||||||
#"2025-12": "2026-01", # keys are merged into values
|
#"2025-12": "2026-01", # keys are merged into values
|
||||||
@@ -76,13 +80,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 +190,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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.cell-overridden {
|
.cell-overridden {
|
||||||
color: #ffa500 !important;
|
color: #ffa500 !important;
|
||||||
}
|
}
|
||||||
@@ -259,6 +265,24 @@
|
|||||||
border-color: #00ff00;
|
border-color: #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
color: #888;
|
color: #888;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
@@ -458,12 +482,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav-archived">
|
<div class="nav-archived">
|
||||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
<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>
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Adults Dashboard</h1>
|
<h1>Adults Dashboard</h1>
|
||||||
@@ -477,6 +502,20 @@
|
|||||||
<div class="filter-container">
|
<div class="filter-container">
|
||||||
<span class="filter-label">search member:</span>
|
<span class="filter-label">search member:</span>
|
||||||
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
<span class="filter-label" style="margin-left: 16px;">from:</span>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="filter-label" style="margin-left: 8px;">to:</span>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="applyMonthFilter()" class="filter-select" style="cursor: pointer;">Apply</button>
|
||||||
|
<button type="button" onclick="resetMonthFilter()" class="filter-select" style="cursor: pointer;">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -485,7 +524,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
{% for m in months %}
|
{% for m in months %}
|
||||||
<th>{{ m }}</th>
|
<th data-month-idx="{{ loop.index0 }}">{{ m }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -498,20 +537,20 @@
|
|||||||
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
</td>
|
</td>
|
||||||
{% for cell in row.months %}
|
{% for cell in row.months %}
|
||||||
<td title="{{ cell.tooltip }}"
|
<td data-month-idx="{{ loop.index0 }}" 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 %}">
|
class="{% if cell.status == 'empty' %}cell-empty{% elif (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month >= current_month %}cell-unpaid-current{% 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 }}
|
{{ cell.text }}
|
||||||
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
{% if (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month < current_month %}
|
||||||
<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 %}
|
||||||
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
<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" }}
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
{% if row.balance < 0 %}
|
{% if row.payable_amount > 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.payable_amount }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -521,7 +560,7 @@
|
|||||||
TOTAL
|
TOTAL
|
||||||
</td>
|
</td>
|
||||||
{% for t in totals %}
|
{% 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;">
|
<td data-month-idx="{{ loop.index0 }}" 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>
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
{{ t.text }}
|
{{ t.text }}
|
||||||
</td>
|
</td>
|
||||||
@@ -647,7 +686,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>
|
||||||
@@ -857,9 +896,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');
|
||||||
@@ -884,6 +927,64 @@
|
|||||||
event.target.style.display = 'none';
|
event.target.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Month range filter
|
||||||
|
var maxMonthIdx;
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
var fromIdx = parseInt(document.getElementById('fromMonth').value);
|
||||||
|
var toIdx = parseInt(document.getElementById('toMonth').value);
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-month-idx'));
|
||||||
|
if (idx >= fromIdx && idx <= toIdx) {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMonthFilter() {
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
fromSelect.value = 0;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove future months from selects, set defaults, apply on load
|
||||||
|
(function() {
|
||||||
|
var now = new Date();
|
||||||
|
var currentMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
maxMonthIdx = sortedMonths.length - 1;
|
||||||
|
for (var i = 0; i < sortedMonths.length; i++) {
|
||||||
|
if (sortedMonths[i] > currentMonth) {
|
||||||
|
maxMonthIdx = i - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
|
||||||
|
// Remove future month options
|
||||||
|
for (var i = fromSelect.options.length - 1; i > maxMonthIdx; i--) {
|
||||||
|
fromSelect.remove(i);
|
||||||
|
toSelect.remove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide future month columns permanently
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
if (parseInt(el.getAttribute('data-month-idx')) > maxMonthIdx) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
||||||
|
fromSelect.value = defaultFrom;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Junior Fees 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid #333;
|
|
||||||
box-shadow: none;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #00ff00;
|
|
||||||
border-top: 1px solid #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-empty {
|
|
||||||
color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-paid {
|
|
||||||
color: #aaaaaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-overridden {
|
|
||||||
color: #ffa500 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</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" 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>
|
|
||||||
|
|
||||||
<div class="description">
|
|
||||||
Calculated monthly fees based on attendance markers.<br>
|
|
||||||
Source: <a href="{{ attendance_url }}" target="_blank">Junior Attendance Sheet</a> |
|
|
||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
{% for m in months %}
|
|
||||||
<th>{{ m }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in results %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.name }}</td>
|
|
||||||
{% for mdata in row.months %}
|
|
||||||
<td
|
|
||||||
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
|
|
||||||
{{ mdata.cell }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="total">
|
|
||||||
<td>TOTAL</td>
|
|
||||||
{% for t in totals %}
|
|
||||||
<td>{{ t }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</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>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Fees Dashboard</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
background-color: #0c0c0c;
|
|
||||||
/* Deeper black */
|
|
||||||
color: #cccccc;
|
|
||||||
/* Base gray terminal text */
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 11px;
|
|
||||||
/* Even smaller font */
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #00ff00;
|
|
||||||
/* Terminal green */
|
|
||||||
font-family: inherit;
|
|
||||||
/* Use monospace for header too */
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
background-color: transparent;
|
|
||||||
/* Remove the card background */
|
|
||||||
border: 1px solid #333;
|
|
||||||
/* Just a thin outline if needed, or none */
|
|
||||||
box-shadow: none;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
table-layout: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 2px 8px;
|
|
||||||
/* Extremely tight padding */
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: 1px dashed #222;
|
|
||||||
/* Dashed lines for terminal feel */
|
|
||||||
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;
|
|
||||||
/* Stronger border for header */
|
|
||||||
text-transform: lowercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
/* Very subtle hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #00ff00;
|
|
||||||
/* Highlight total row */
|
|
||||||
border-top: 1px solid #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-empty {
|
|
||||||
color: #444444;
|
|
||||||
/* Darker gray for empty cells */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-paid {
|
|
||||||
color: #aaaaaa;
|
|
||||||
/* Light gray for normal cells */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-overridden {
|
|
||||||
color: #ffa500 !important;
|
|
||||||
/* Orange for overrides */
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</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" 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>
|
|
||||||
|
|
||||||
<div class="description">
|
|
||||||
Calculated monthly fees based on attendance markers.<br>
|
|
||||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
|
||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
{% for m in months %}
|
|
||||||
<th>{{ m }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in results %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.name }}</td>
|
|
||||||
{% for mdata in row.months %}
|
|
||||||
<td
|
|
||||||
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
|
|
||||||
{{ mdata.cell }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="total">
|
|
||||||
<td>TOTAL</td>
|
|
||||||
{% for t in totals %}
|
|
||||||
<td>{{ t }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</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>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
163
templates/flush-cache.html
Normal file
163
templates/flush-cache.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ - Flush Cache</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-container {
|
||||||
|
background-color: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #00ff00;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
padding: 8px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-btn:hover {
|
||||||
|
background-color: #00ff00;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: #00ff00; }
|
||||||
|
|
||||||
|
.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="/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>
|
||||||
|
<a href="/flush-cache" class="active">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Flush Cache</h1>
|
||||||
|
|
||||||
|
{% if flushed %}
|
||||||
|
<div class="status">
|
||||||
|
<span class="status-ok">Cache flushed successfully. {{ deleted }} file(s) deleted.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flush-container">
|
||||||
|
<p style="margin-bottom: 20px; color: #888;">Clears all cached Google Sheets data and resets refresh timers.</p>
|
||||||
|
<form method="POST" action="/flush-cache">
|
||||||
|
<button type="submit" class="flush-btn">[Flush Cache]</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -167,6 +167,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.cell-overridden {
|
.cell-overridden {
|
||||||
color: #ffa500 !important;
|
color: #ffa500 !important;
|
||||||
}
|
}
|
||||||
@@ -259,6 +265,24 @@
|
|||||||
border-color: #00ff00;
|
border-color: #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
color: #888;
|
color: #888;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
@@ -458,12 +482,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav-archived">
|
<div class="nav-archived">
|
||||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
<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>
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Juniors Dashboard</h1>
|
<h1>Juniors Dashboard</h1>
|
||||||
@@ -477,6 +502,20 @@
|
|||||||
<div class="filter-container">
|
<div class="filter-container">
|
||||||
<span class="filter-label">search member:</span>
|
<span class="filter-label">search member:</span>
|
||||||
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
<span class="filter-label" style="margin-left: 16px;">from:</span>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="filter-label" style="margin-left: 8px;">to:</span>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="applyMonthFilter()" class="filter-select" style="cursor: pointer;">Apply</button>
|
||||||
|
<button type="button" onclick="resetMonthFilter()" class="filter-select" style="cursor: pointer;">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -485,7 +524,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
{% for m in months %}
|
{% for m in months %}
|
||||||
<th>{{ m }}</th>
|
<th data-month-idx="{{ loop.index0 }}">{{ m }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -498,20 +537,20 @@
|
|||||||
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
</td>
|
</td>
|
||||||
{% for cell in row.months %}
|
{% for cell in row.months %}
|
||||||
<td title="{{ cell.tooltip }}"
|
<td data-month-idx="{{ loop.index0 }}" 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 %}">
|
class="{% if cell.status == 'empty' %}cell-empty{% elif (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month >= current_month %}cell-unpaid-current{% 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 }}
|
{{ cell.text }}
|
||||||
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
{% if (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month < current_month %}
|
||||||
<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 %}
|
||||||
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
<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" }}
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
{% if row.balance < 0 %}
|
{% if row.payable_amount > 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.payable_amount }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -521,7 +560,7 @@
|
|||||||
TOTAL
|
TOTAL
|
||||||
</td>
|
</td>
|
||||||
{% for t in totals %}
|
{% 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;">
|
<td data-month-idx="{{ loop.index0 }}" 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>
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
{{ t.text }}
|
{{ t.text }}
|
||||||
</td>
|
</td>
|
||||||
@@ -628,7 +667,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>
|
||||||
@@ -838,9 +877,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');
|
||||||
@@ -865,6 +908,64 @@
|
|||||||
event.target.style.display = 'none';
|
event.target.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Month range filter
|
||||||
|
var maxMonthIdx;
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
var fromIdx = parseInt(document.getElementById('fromMonth').value);
|
||||||
|
var toIdx = parseInt(document.getElementById('toMonth').value);
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-month-idx'));
|
||||||
|
if (idx >= fromIdx && idx <= toIdx) {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMonthFilter() {
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
fromSelect.value = 0;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove future months from selects, set defaults, apply on load
|
||||||
|
(function() {
|
||||||
|
var now = new Date();
|
||||||
|
var currentMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
maxMonthIdx = sortedMonths.length - 1;
|
||||||
|
for (var i = 0; i < sortedMonths.length; i++) {
|
||||||
|
if (sortedMonths[i] > currentMonth) {
|
||||||
|
maxMonthIdx = i - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
|
||||||
|
// Remove future month options
|
||||||
|
for (var i = fromSelect.options.length - 1; i > maxMonthIdx; i--) {
|
||||||
|
fromSelect.remove(i);
|
||||||
|
toSelect.remove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide future month columns permanently
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
if (parseInt(el.getAttribute('data-month-idx')) > maxMonthIdx) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
||||||
|
fromSelect.value = defaultFrom;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -190,12 +190,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav-archived">
|
<div class="nav-archived">
|
||||||
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
<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>
|
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Payments Ledger</h1>
|
<h1>Payments Ledger</h1>
|
||||||
@@ -237,7 +238,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>
|
||||||
|
|||||||
@@ -1,875 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Junior Payment Reconciliation</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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">[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>
|
|
||||||
|
|
||||||
<div class="description">
|
|
||||||
Balances calculated by matching Google Sheet payments against junior 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
|
|
||||||
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% 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 %}
|
|
||||||
</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 = 'OK'; statusClass = 'cell-ok'; }
|
|
||||||
else if (paid > 0) { status = paid + '/' + expected; }
|
|
||||||
else { status = 'UNPAID ' + 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>
|
|
||||||
```
|
|
||||||
@@ -1,875 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Payment Reconciliation</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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">[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>
|
|
||||||
|
|
||||||
<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
|
|
||||||
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% 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 %}
|
|
||||||
</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 = 'OK'; statusClass = 'cell-ok'; }
|
|
||||||
else if (paid > 0) { status = paid + '/' + expected; }
|
|
||||||
else { status = 'UNPAID ' + 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>
|
|
||||||
```
|
|
||||||
153
templates/sync.html
Normal file
153
templates/sync.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<!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="/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>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</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>
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user