Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee2dd782d | ||
|
|
4bb8c7420c | ||
|
|
b0276f68b3 | ||
|
|
7d05e3812c | ||
|
|
815b962dd7 |
@@ -1,5 +1,7 @@
|
|||||||
# Antigravity Agent Configuration
|
# Antigravity Agent Configuration
|
||||||
# This file provides global rules for the Antigravity agent when working on this repository.
|
# This file provides global rules for the Antigravity agent when working on this repository.
|
||||||
|
|
||||||
- **Git Commits**: When making git commits, always append the following co-author trailer to the end of the commit message to indicate AI assistance:
|
- **Identity**: Antigravity AI (Assistant)
|
||||||
`Co-authored-by: Antigravity <antigravity@deepmind.com>`
|
- **Git Commits**: Always follow [Conventional Commits](https://www.conventionalcommits.org/) and append the co-author trailer:
|
||||||
|
`Co-authored-by: Antigravity <antigravity@google.com>`
|
||||||
|
- **Workflow**: Prefer updating `task.md` and `walkthrough.md` in the `.gemini/antigravity/brain/` directory to track progress and document changes.
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
||||||
148
app.py
148
app.py
@@ -2,17 +2,53 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
from flask import Flask, render_template
|
import time
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import qrcode
|
||||||
|
from flask import Flask, render_template, g, send_file, request
|
||||||
|
|
||||||
# Add scripts directory to path to allow importing from it
|
# Add scripts directory to path to allow importing from it
|
||||||
scripts_dir = Path(__file__).parent / "scripts"
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
sys.path.append(str(scripts_dir))
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID
|
from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID
|
||||||
from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Bank account for QR code payments (can be overridden by ENV)
|
||||||
|
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def start_timer():
|
||||||
|
g.start_time = time.perf_counter()
|
||||||
|
g.steps = []
|
||||||
|
|
||||||
|
def record_step(name):
|
||||||
|
g.steps.append((name, time.perf_counter()))
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_render_time():
|
||||||
|
def get_render_time():
|
||||||
|
total = time.perf_counter() - g.start_time
|
||||||
|
breakdown = []
|
||||||
|
last_time = g.start_time
|
||||||
|
for name, timestamp in g.steps:
|
||||||
|
duration = timestamp - last_time
|
||||||
|
breakdown.append(f"{name}:{duration:.3f}s")
|
||||||
|
last_time = timestamp
|
||||||
|
|
||||||
|
# Add remaining time as 'render'
|
||||||
|
render_duration = time.perf_counter() - last_time
|
||||||
|
breakdown.append(f"render:{render_duration:.3f}s")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": f"{total:.3f}",
|
||||||
|
"breakdown": " | ".join(breakdown)
|
||||||
|
}
|
||||||
|
return dict(get_render_time=get_render_time)
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
# Redirect root to /fees for convenience while there are no other apps
|
# Redirect root to /fees for convenience while there are no other apps
|
||||||
@@ -24,6 +60,7 @@ def fees():
|
|||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members, sorted_months = get_members_with_fees()
|
||||||
|
record_step("fetch_members")
|
||||||
if not members:
|
if not members:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
|
||||||
@@ -37,16 +74,35 @@ def fees():
|
|||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
monthly_totals = {m: 0 for m in sorted_months}
|
||||||
|
|
||||||
|
# Get exceptions for formatting
|
||||||
|
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||||
|
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for name, month_fees in results:
|
for name, month_fees in results:
|
||||||
row = {"name": name, "months": []}
|
row = {"name": name, "months": []}
|
||||||
|
norm_name = normalize(name)
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
fee, count = month_fees.get(m, (0, 0))
|
fee, count = month_fees.get(m, (0, 0))
|
||||||
monthly_totals[m] += fee
|
monthly_totals[m] += fee
|
||||||
|
|
||||||
|
# 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:
|
||||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||||
row["months"].append(cell)
|
is_overridden = False
|
||||||
|
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"fees.html",
|
"fees.html",
|
||||||
months=[month_labels[m] for m in sorted_months],
|
months=[month_labels[m] for m in sorted_months],
|
||||||
@@ -65,11 +121,16 @@ def reconcile_view():
|
|||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members, sorted_months = get_members_with_fees()
|
||||||
|
record_step("fetch_members")
|
||||||
if not members:
|
if not members:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
|
||||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
result = reconcile(members, sorted_months, transactions)
|
record_step("fetch_payments")
|
||||||
|
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
record_step("reconcile")
|
||||||
|
|
||||||
# Format month labels
|
# Format month labels
|
||||||
month_labels = {
|
month_labels = {
|
||||||
@@ -84,21 +145,36 @@ def reconcile_view():
|
|||||||
data = result["members"][name]
|
data = result["members"][name]
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"]}
|
row = {"name": name, "months": [], "balance": data["total_balance"]}
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
||||||
expected = mdata["expected"]
|
expected = mdata["expected"]
|
||||||
paid = int(mdata["paid"])
|
paid = int(mdata["paid"])
|
||||||
|
|
||||||
cell_status = ""
|
status = "empty"
|
||||||
if expected == 0 and paid == 0:
|
cell_text = "-"
|
||||||
cell = "-"
|
amount_to_pay = 0
|
||||||
elif paid >= expected and expected > 0:
|
|
||||||
cell = "OK"
|
|
||||||
elif paid > 0:
|
|
||||||
cell = f"{paid}/{expected}"
|
|
||||||
else:
|
|
||||||
cell = f"UNPAID {expected}"
|
|
||||||
|
|
||||||
row["months"].append(cell)
|
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
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"UNPAID {expected}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
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["balance"] = data["total_balance"] # Updated to use total_balance
|
row["balance"] = data["total_balance"] # Updated to use total_balance
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
@@ -106,19 +182,24 @@ def reconcile_view():
|
|||||||
# 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"])
|
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"])
|
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"])
|
||||||
|
|
||||||
# Format unmatched
|
# Format unmatched
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
|
import json
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reconcile.html",
|
"reconcile.html",
|
||||||
months=[month_labels[m] for m in sorted_months],
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
results=formatted_results,
|
results=formatted_results,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
debts=debts,
|
debts=debts,
|
||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url
|
payments_url=payments_url,
|
||||||
|
bank_account=BANK_ACCOUNT
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
@@ -128,6 +209,7 @@ def payments():
|
|||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||||
|
|
||||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_payments")
|
||||||
|
|
||||||
# Group transactions by person
|
# Group transactions by person
|
||||||
grouped = {}
|
grouped = {}
|
||||||
@@ -151,6 +233,7 @@ def payments():
|
|||||||
# Sort by date descending
|
# Sort by date descending
|
||||||
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
return render_template(
|
return render_template(
|
||||||
"payments.html",
|
"payments.html",
|
||||||
grouped_payments=grouped,
|
grouped_payments=grouped,
|
||||||
@@ -159,5 +242,36 @@ def payments():
|
|||||||
payments_url=payments_url
|
payments_url=payments_url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route("/qr")
|
||||||
|
def qr_code():
|
||||||
|
account = request.args.get("account", BANK_ACCOUNT)
|
||||||
|
amount = request.args.get("amount", "0")
|
||||||
|
message = request.args.get("message", "")
|
||||||
|
|
||||||
|
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||||
|
acc_parts = account.split('/')
|
||||||
|
if len(acc_parts) == 2:
|
||||||
|
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
|
||||||
|
else:
|
||||||
|
acc_str = account
|
||||||
|
|
||||||
|
try:
|
||||||
|
amt_val = float(amount)
|
||||||
|
amt_str = f"{amt_val:.2f}"
|
||||||
|
except ValueError:
|
||||||
|
amt_str = "0.00"
|
||||||
|
|
||||||
|
# Message max 60 characters
|
||||||
|
msg_str = message[:60]
|
||||||
|
|
||||||
|
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||||
|
|
||||||
|
img = qrcode.make(qr_data)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
return send_file(buf, mimetype='image/png')
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ RUN pip install --no-cache-dir \
|
|||||||
flask \
|
flask \
|
||||||
google-api-python-client \
|
google-api-python-client \
|
||||||
google-auth-httplib2 \
|
google-auth-httplib2 \
|
||||||
google-auth-oauthlib
|
google-auth-oauthlib \
|
||||||
|
qrcode \
|
||||||
|
pillow
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py Makefile ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.06"
|
version = "0.10"
|
||||||
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
|
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"google-api-python-client>=2.162.0",
|
"google-api-python-client>=2.162.0",
|
||||||
"google-auth-httplib2>=0.2.0",
|
"google-auth-httplib2>=0.2.0",
|
||||||
"google-auth-oauthlib>=1.2.1",
|
"google-auth-oauthlib>=1.2.1",
|
||||||
|
"qrcode[pil]>=8.0",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
|||||||
@@ -233,10 +233,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
|
||||||
|
"""Fetch manual fee overrides from the 'exceptions' sheet.
|
||||||
|
|
||||||
|
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
|
||||||
|
"""
|
||||||
|
service = get_sheets_service(credentials_path)
|
||||||
|
try:
|
||||||
|
result = service.spreadsheets().values().get(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
range="'exceptions'!A2:D",
|
||||||
|
valueRenderOption="UNFORMATTED_VALUE"
|
||||||
|
).execute()
|
||||||
|
rows = result.get("values", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not fetch exceptions: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
exceptions = {}
|
||||||
|
for row in rows:
|
||||||
|
if len(row) < 3 or str(row[0]).lower().startswith("name"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = str(row[0]).strip()
|
||||||
|
period = str(row[1]).strip()
|
||||||
|
# Robust normalization using czech_utils.normalize
|
||||||
|
norm_name = normalize(name)
|
||||||
|
norm_period = normalize(period)
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = int(row[2])
|
||||||
|
note = str(row[3]).strip() if len(row) > 3 else ""
|
||||||
|
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return exceptions
|
||||||
|
|
||||||
|
|
||||||
def reconcile(
|
def reconcile(
|
||||||
members: list[tuple[str, str, dict[str, int]]],
|
members: list[tuple[str, str, dict[str, int]]],
|
||||||
sorted_months: list[str],
|
sorted_months: list[str],
|
||||||
transactions: list[dict],
|
transactions: list[dict],
|
||||||
|
exceptions: dict[tuple[str, str], dict] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Match transactions to members and months.
|
"""Match transactions to members and months.
|
||||||
|
|
||||||
@@ -251,11 +290,30 @@ def reconcile(
|
|||||||
|
|
||||||
# Initialize ledger
|
# Initialize ledger
|
||||||
ledger: dict[str, dict[str, dict]] = {}
|
ledger: dict[str, dict[str, dict]] = {}
|
||||||
|
exceptions = exceptions or {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
ledger[name] = {}
|
ledger[name] = {}
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
|
# Robust normalization for lookup
|
||||||
|
norm_name = normalize(name)
|
||||||
|
norm_period = normalize(m)
|
||||||
|
fee_data = member_fees[name].get(m, (0, 0))
|
||||||
|
original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data
|
||||||
|
attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0
|
||||||
|
|
||||||
|
ex_data = exceptions.get((norm_name, norm_period))
|
||||||
|
if ex_data is not None:
|
||||||
|
expected = ex_data["amount"]
|
||||||
|
exception_info = ex_data
|
||||||
|
else:
|
||||||
|
expected = original_expected
|
||||||
|
exception_info = None
|
||||||
|
|
||||||
ledger[name][m] = {
|
ledger[name][m] = {
|
||||||
"expected": member_fees[name].get(m, 0),
|
"expected": expected,
|
||||||
|
"original_expected": original_expected,
|
||||||
|
"attendance_count": attendance_count,
|
||||||
|
"exception": exception_info,
|
||||||
"paid": 0,
|
"paid": 0,
|
||||||
"transactions": [],
|
"transactions": [],
|
||||||
}
|
}
|
||||||
@@ -392,10 +450,12 @@ def print_report(result: dict, sorted_months: list[str]):
|
|||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
expected = mdata["expected"]
|
expected = mdata["expected"]
|
||||||
|
original = mdata["original_expected"]
|
||||||
paid = int(mdata["paid"])
|
paid = int(mdata["paid"])
|
||||||
total_expected += expected
|
total_expected += expected
|
||||||
total_paid += paid
|
total_paid += paid
|
||||||
|
|
||||||
|
cell_status = ""
|
||||||
if expected == 0 and paid == 0:
|
if expected == 0 and paid == 0:
|
||||||
cell = "-"
|
cell = "-"
|
||||||
elif paid >= expected and expected > 0:
|
elif paid >= expected and expected > 0:
|
||||||
@@ -404,6 +464,7 @@ def print_report(result: dict, sorted_months: list[str]):
|
|||||||
cell = f"{paid}/{expected}"
|
cell = f"{paid}/{expected}"
|
||||||
else:
|
else:
|
||||||
cell = f"UNPAID {expected}"
|
cell = f"UNPAID {expected}"
|
||||||
|
|
||||||
member_balance += paid - expected
|
member_balance += paid - expected
|
||||||
line += f" | {cell:>10}"
|
line += f" | {cell:>10}"
|
||||||
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
|
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
|
||||||
@@ -509,7 +570,11 @@ def main():
|
|||||||
|
|
||||||
print(f"Processing {len(transactions)} transactions.\n")
|
print(f"Processing {len(transactions)} transactions.\n")
|
||||||
|
|
||||||
result = reconcile(members, sorted_months, transactions)
|
exceptions = fetch_exceptions(args.sheet_id, args.credentials)
|
||||||
|
if exceptions:
|
||||||
|
print(f"Loaded {len(exceptions)} fee exceptions.")
|
||||||
|
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
print_report(result, sorted_months)
|
print_report(result, sorted_months)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,11 @@
|
|||||||
/* Light gray for normal cells */
|
/* Light gray for normal cells */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
/* Orange for overrides */
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -143,6 +148,23 @@
|
|||||||
.description a:hover {
|
.description a:hover {
|
||||||
text-decoration: underline;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -175,8 +197,11 @@
|
|||||||
{% for row in results %}
|
{% for row in results %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ row.name }}</td>
|
<td>{{ row.name }}</td>
|
||||||
{% for cell in row.months %}
|
{% for mdata in row.months %}
|
||||||
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</td>
|
<td
|
||||||
|
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
|
||||||
|
{{ mdata.cell }}
|
||||||
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -191,6 +216,14 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</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>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -137,6 +137,23 @@
|
|||||||
tr:hover {
|
tr:hover {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -183,6 +200,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</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>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -138,6 +138,28 @@
|
|||||||
|
|
||||||
.cell-unpaid {
|
.cell-unpaid {
|
||||||
color: #ff3333;
|
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 {
|
.cell-empty {
|
||||||
@@ -183,6 +205,219 @@
|
|||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
margin-bottom: 5px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -201,6 +436,11 @@
|
|||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
</div>
|
</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">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -212,14 +452,21 @@
|
|||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="reconcileBody">
|
||||||
{% for row in results %}
|
{% for row in results %}
|
||||||
<tr>
|
<tr class="member-row">
|
||||||
<td>{{ row.name }}</td>
|
<td class="member-name">
|
||||||
|
{{ row.name }}
|
||||||
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
|
</td>
|
||||||
{% for cell in row.months %}
|
{% for cell in row.months %}
|
||||||
<td
|
<td
|
||||||
class="{% if cell == '-' %}cell-empty{% elif 'UNPAID' in cell %}cell-unpaid{% elif cell == 'OK' %}cell-ok{% endif %}">
|
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 }}
|
{{ 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>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
|
||||||
@@ -275,6 +522,245 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
||||||
|
<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 }};
|
||||||
|
|
||||||
|
function showMemberDetails(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 row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="color: #888;">${m}</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 item = document.createElement('div');
|
||||||
|
item.className = 'tx-item'; // Reuse style
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${ex.month}</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 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 item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | matched to ${tx.month}</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
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
```
|
||||||
56
tests/test_reconcile_exceptions.py
Normal file
56
tests/test_reconcile_exceptions.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import unittest
|
||||||
|
from scripts.match_payments import reconcile
|
||||||
|
|
||||||
|
class TestReconcileWithExceptions(unittest.TestCase):
|
||||||
|
def test_reconcile_applies_exceptions(self):
|
||||||
|
# 1. Setup mock data
|
||||||
|
# Member: Alice, Tier A, expected 750 (attendance-based)
|
||||||
|
members = [
|
||||||
|
('Alice', 'A', {'2026-01': (750, 4)})
|
||||||
|
]
|
||||||
|
sorted_months = ['2026-01']
|
||||||
|
|
||||||
|
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
|
||||||
|
exceptions = {
|
||||||
|
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Transaction: Alice paid 400
|
||||||
|
transactions = [{
|
||||||
|
'date': '2026-01-05',
|
||||||
|
'amount': 400,
|
||||||
|
'person': 'Alice',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'inferred_amount': 400,
|
||||||
|
'sender': 'Alice Sender',
|
||||||
|
'message': 'fee'
|
||||||
|
}]
|
||||||
|
|
||||||
|
# 2. Reconcile
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
|
||||||
|
# 3. Assertions
|
||||||
|
alice_data = result['members']['Alice']
|
||||||
|
jan_data = alice_data['months']['2026-01']
|
||||||
|
|
||||||
|
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
|
||||||
|
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
|
||||||
|
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
|
||||||
|
|
||||||
|
def test_reconcile_fallback_to_attendance(self):
|
||||||
|
# Alice has attendance-based fee 750, NO exception
|
||||||
|
members = [
|
||||||
|
('Alice', 'A', {'2026-01': (750, 4)})
|
||||||
|
]
|
||||||
|
sorted_months = ['2026-01']
|
||||||
|
exceptions = {} # No exceptions
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
|
||||||
|
alice_data = result['members']['Alice']
|
||||||
|
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user