Compare commits
35 Commits
0.03
...
083a51023c
| Author | SHA1 | Date | |
|---|---|---|---|
| 083a51023c | |||
| 54762cd421 | |||
| b2aaca5df9 | |||
| 883bc4489e | |||
| 3ad4a21f5b | |||
| 3c1604c7af | |||
| 8b3223f865 | |||
| 276e18a9c8 | |||
| 61f2126c1b | |||
| 3377092a3f | |||
| dca0c6c933 | |||
| 9b99f6d33b | |||
| e83d6af1f5 | |||
| 7d51f9ca77 | |||
| 033349cafa | |||
| 0d0c2af778 | |||
| 7170cd4d27 | |||
| 251d7ba6b5 | |||
| 76cdcba424 | |||
| 8662cb4592 | |||
| c8c145486f | |||
|
|
27ad66ff79 | ||
|
|
1257f0d644 | ||
|
|
75a36eb49b | ||
|
|
f40015a2ef | ||
|
|
5bdc7a4566 | ||
|
|
9ee2dd782d | ||
|
|
4bb8c7420c | ||
|
|
b0276f68b3 | ||
|
|
7d05e3812c | ||
|
|
815b962dd7 | ||
|
|
99b23199b1 | ||
|
|
70d6794a3c | ||
|
|
ed5c9bf173 | ||
|
|
786cddba4d |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
# python cache
|
# python cache
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
.secret
|
.secret
|
||||||
|
|
||||||
|
# local tmp folder
|
||||||
|
tmp/
|
||||||
|
|||||||
33
.vscode/launch.json
vendored
Normal file
33
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Flask",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "flask",
|
||||||
|
"python": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"env": {
|
||||||
|
"FLASK_APP": "app.py",
|
||||||
|
"FLASK_DEBUG": "1"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--no-debugger",
|
||||||
|
"--no-reload",
|
||||||
|
"--host", "0.0.0.0",
|
||||||
|
"--port", "5001"
|
||||||
|
],
|
||||||
|
"jinja": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Attach",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"connect": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5678
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -4,22 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club.
|
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||||
|
|
||||||
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
|
|
||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||||
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform.
|
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
This project uses a hybrid workflow:
|
|
||||||
- Claude.ai chat for brainstorming and design exploration
|
|
||||||
- Claude Code for implementation
|
|
||||||
|
|
||||||
## When Code Exists
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -40,7 +30,7 @@ Alternatively, use the Makefile:
|
|||||||
- `make web` - Start dashboard
|
- `make web` - Start dashboard
|
||||||
- `make image` - Build Docker image
|
- `make image` - Build Docker image
|
||||||
|
|
||||||
Requires `credentials.json` in the root for Google Sheets API access.
|
Requires `.secret/fuj-management-bot-credentials.json` for Google Sheets API access (configurable via `CREDENTIALS_PATH` env var).
|
||||||
|
|
||||||
## Git Commits
|
## Git Commits
|
||||||
|
|
||||||
|
|||||||
28
Makefile
28
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help fees match web image run sync sync-2026 test test-v
|
.PHONY: help fees match web web-debug image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -15,16 +15,19 @@ help:
|
|||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
@echo " make web - Start a dynamic web dashboard locally"
|
@echo " make web - Start a dynamic web dashboard locally"
|
||||||
@echo " make image - Build an OCI container image"
|
@echo " make web-debug - Start a dynamic web dashboard locally in debug mode"
|
||||||
|
@echo " make image - Build an OCI container image"
|
||||||
@echo " make run - Run the built Docker image locally"
|
@echo " make run - Run the built Docker image locally"
|
||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||||
@echo " make test - Run web application infrastructure tests"
|
@echo " make test - Run web application infrastructure tests"
|
||||||
@echo " make test-v - Run tests with verbose output"
|
@echo " make test-v - Run tests with verbose output"
|
||||||
|
@echo " make docs - Serve documentation in a browser"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
uv sync
|
uv sync
|
||||||
@@ -38,15 +41,25 @@ match: $(PYTHON)
|
|||||||
web: $(PYTHON)
|
web: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) app.py
|
||||||
|
|
||||||
|
web-debug: $(PYTHON)
|
||||||
|
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 fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|
||||||
sync: $(PYTHON)
|
sync: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
||||||
|
|
||||||
|
sync-2025: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||||
|
|
||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
@@ -61,3 +74,8 @@ test: $(PYTHON) ## Run web application tests
|
|||||||
|
|
||||||
test-v: $(PYTHON) ## Run tests with verbose output
|
test-v: $(PYTHON) ## Run tests with verbose output
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
|
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
|
||||||
|
|
||||||
|
docs: ## Serve documentation locally
|
||||||
|
@echo "Starting documentation server at http://localhost:8000"
|
||||||
|
@echo "Press Ctrl+C to stop."
|
||||||
|
$(PYTHON) -m http.server 8000 --directory docs
|
||||||
|
|||||||
857
app.py
857
app.py
@@ -2,51 +2,215 @@ 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
|
||||||
|
import logging
|
||||||
|
from flask import Flask, render_template, g, send_file, request
|
||||||
|
|
||||||
|
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||||
|
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
|
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
# 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 config import (
|
||||||
from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||||
|
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||||
|
)
|
||||||
|
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 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):
|
||||||
|
mod_time = get_sheet_modified_time(cache_key)
|
||||||
|
if mod_time:
|
||||||
|
cached = read_cache(cache_key, mod_time)
|
||||||
|
if cached is not None:
|
||||||
|
return deserialize(cached) if deserialize else cached
|
||||||
|
data = fetch_func(*args, **kwargs)
|
||||||
|
if mod_time:
|
||||||
|
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_month_labels(sorted_months, merged_months):
|
||||||
|
labels = {}
|
||||||
|
for m in sorted_months:
|
||||||
|
dt = datetime.strptime(m, "%Y-%m")
|
||||||
|
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
|
||||||
|
merged_in = sorted([k for k, v in merged_months.items() if v == m])
|
||||||
|
if merged_in:
|
||||||
|
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
|
||||||
|
years = {d.year for d in all_dts}
|
||||||
|
if len(years) > 1:
|
||||||
|
parts = [d.strftime("%b %Y") for d in all_dts]
|
||||||
|
labels[m] = "+".join(parts)
|
||||||
|
else:
|
||||||
|
parts = [d.strftime("%b") for d in all_dts]
|
||||||
|
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
||||||
|
else:
|
||||||
|
labels[m] = dt.strftime("%b %Y")
|
||||||
|
return labels
|
||||||
|
|
||||||
|
def warmup_cache():
|
||||||
|
"""Pre-fetch all cached data so first request is fast."""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("Warming up cache...")
|
||||||
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
|
get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
|
get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
|
get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
@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, build_meta=BUILD_META)
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
# Redirect root to /fees for convenience while there are no other apps
|
# Redirect root to /adults for convenience while there are no other apps
|
||||||
return '<meta http-equiv="refresh" content="0; url=/fees" />'
|
return '<meta http-equiv="refresh" content="0; url=/adults" />'
|
||||||
|
|
||||||
|
@app.route("/flush-cache", methods=["GET", "POST"])
|
||||||
|
def flush_cache_endpoint():
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("flush-cache.html")
|
||||||
|
deleted = flush_cache()
|
||||||
|
return render_template("flush-cache.html", flushed=True, deleted=deleted)
|
||||||
|
|
||||||
|
@app.route("/sync-bank")
|
||||||
|
def sync_bank():
|
||||||
|
import contextlib
|
||||||
|
output = io.StringIO()
|
||||||
|
success = True
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||||
|
# sync_to_sheets: equivalent of make sync-2026
|
||||||
|
output.write("=== Syncing Fio transactions (2026) ===\n")
|
||||||
|
sync_to_sheets(
|
||||||
|
spreadsheet_id=PAYMENTS_SHEET_ID,
|
||||||
|
credentials_path=CREDENTIALS_PATH,
|
||||||
|
date_from_str="2026-01-01",
|
||||||
|
date_to_str="2026-12-31",
|
||||||
|
sort_by_date=True,
|
||||||
|
)
|
||||||
|
output.write("\n=== Inferring payment details ===\n")
|
||||||
|
infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||||
|
output.write("\n=== Flushing cache ===\n")
|
||||||
|
deleted = flush_cache()
|
||||||
|
output.write(f"Deleted {deleted} cache files.\n")
|
||||||
|
output.write("\n=== Done ===\n")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
output.write(f"\n!!! Error: {e}\n")
|
||||||
|
output.write(traceback.format_exc())
|
||||||
|
success = False
|
||||||
|
return render_template("sync.html", output=output.getvalue(), success=success)
|
||||||
|
|
||||||
|
@app.route("/version")
|
||||||
|
def version():
|
||||||
|
return BUILD_META
|
||||||
|
|
||||||
@app.route("/fees")
|
@app.route("/fees")
|
||||||
def fees():
|
def fees():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
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_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
if not members:
|
record_step("fetch_members")
|
||||||
|
if not members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
members, sorted_months = members_data
|
||||||
|
|
||||||
# Filter to adults only for display
|
# Filter to adults only for display
|
||||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
||||||
|
|
||||||
# Format month labels
|
# Format month labels
|
||||||
month_labels = {
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
|
||||||
}
|
|
||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
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 = []
|
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
|
|
||||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
# Check for exception
|
||||||
row["months"].append(cell)
|
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)
|
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],
|
||||||
@@ -56,25 +220,256 @@ def fees():
|
|||||||
payments_url=payments_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")
|
||||||
|
def adults_view():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
|
record_step("fetch_members")
|
||||||
|
if not members_data:
|
||||||
|
return "No data."
|
||||||
|
members, sorted_months = members_data
|
||||||
|
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_payments")
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
record_step("reconcile")
|
||||||
|
|
||||||
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in adult_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0:
|
||||||
|
amount_to_pay = max(0, expected - paid)
|
||||||
|
if paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
else:
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0 or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip
|
||||||
|
})
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = data["total_balance"]
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
|
||||||
|
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
|
||||||
|
unmatched = result["unmatched"]
|
||||||
|
import json
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"adults.html",
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=unmatched,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=BANK_ACCOUNT
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/reconcile")
|
@app.route("/reconcile")
|
||||||
def reconcile_view():
|
def reconcile_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
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"
|
||||||
|
|
||||||
# Use hardcoded credentials path for now, consistent with other scripts
|
# Use hardcoded credentials path for now, consistent with other scripts
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
if not members:
|
record_step("fetch_members")
|
||||||
|
if not members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
members, sorted_months = members_data
|
||||||
|
|
||||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
result = reconcile(members, sorted_months, transactions)
|
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
|
# Format month labels
|
||||||
month_labels = {
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
|
||||||
}
|
|
||||||
|
|
||||||
# Filter to adults for the main table
|
# Filter to adults for the main table
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
@@ -82,52 +477,398 @@ def reconcile_view():
|
|||||||
formatted_results = []
|
formatted_results = []
|
||||||
for name in adult_names:
|
for name in adult_names:
|
||||||
data = result["members"][name]
|
data = result["members"][name]
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"]}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
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"
|
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])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"UNPAID {expected}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
cell = f"{paid}/{expected}"
|
status = "surplus"
|
||||||
else:
|
cell_text = f"PAID {paid}"
|
||||||
cell = f"UNPAID {expected}"
|
|
||||||
|
|
||||||
row["months"].append(cell)
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m
|
||||||
|
})
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
row["balance"] = data["total_balance"] # Updated to use total_balance
|
row["balance"] = data["total_balance"] # Updated to use total_balance
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
def juniors_view():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
|
record_step("fetch_junior_members")
|
||||||
|
if not junior_members_data:
|
||||||
|
return "No data."
|
||||||
|
junior_members, sorted_months = junior_members_data
|
||||||
|
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_payments")
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
|
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
||||||
|
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
|
||||||
|
adapted_members = []
|
||||||
|
for name, tier, fees_dict in junior_members:
|
||||||
|
adapted_fees = {}
|
||||||
|
for m, fee_data in fees_dict.items():
|
||||||
|
if len(fee_data) == 4:
|
||||||
|
fee, total_count, _, _ = fee_data
|
||||||
|
adapted_fees[m] = (fee, total_count)
|
||||||
|
else:
|
||||||
|
fee, count = fee_data
|
||||||
|
adapted_fees[m] = (fee, count)
|
||||||
|
adapted_members.append((name, tier, adapted_fees))
|
||||||
|
|
||||||
|
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||||
|
record_step("reconcile")
|
||||||
|
|
||||||
|
# Format month labels
|
||||||
|
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||||
|
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||||
|
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in junior_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
if expected != "?" and isinstance(expected, int):
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||||
|
adult_count = 0
|
||||||
|
junior_count = 0
|
||||||
|
if orig_fee_data and len(orig_fee_data) == 4:
|
||||||
|
_, _, adult_count, junior_count = orig_fee_data
|
||||||
|
|
||||||
|
breakdown = ""
|
||||||
|
if adult_count > 0 and junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J,{adult_count}A"
|
||||||
|
elif junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J"
|
||||||
|
elif adult_count > 0:
|
||||||
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
|
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK{count_str}"
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
|
if expected == "?":
|
||||||
|
status = "empty"
|
||||||
|
cell_text = f"?{count_str}"
|
||||||
|
elif paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
|
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip
|
||||||
|
})
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = data["total_balance"]
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format credits and debts
|
||||||
|
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
|
||||||
|
unmatched = result["unmatched"]
|
||||||
|
import json
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"juniors.html",
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=unmatched,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=BANK_ACCOUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/reconcile-juniors")
|
||||||
|
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])
|
||||||
|
|
||||||
|
junior_members_dict_rc = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
|
||||||
|
formatted_results = []
|
||||||
|
for name in junior_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
||||||
|
expected = mdata["expected"]
|
||||||
|
paid = int(mdata["paid"])
|
||||||
|
|
||||||
|
orig_fee_data = junior_members_dict_rc.get(name, {}).get(m)
|
||||||
|
adult_count = 0
|
||||||
|
junior_count = 0
|
||||||
|
att_count = 0
|
||||||
|
if orig_fee_data and len(orig_fee_data) == 4:
|
||||||
|
_, att_count, adult_count, junior_count = orig_fee_data
|
||||||
|
|
||||||
|
breakdown = ""
|
||||||
|
if adult_count > 0 and junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J,{adult_count}A"
|
||||||
|
elif junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J"
|
||||||
|
elif adult_count > 0:
|
||||||
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
|
count_str = f" ({att_count}{breakdown})" if att_count > 0 else ""
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
|
if expected == "?":
|
||||||
|
status = "empty"
|
||||||
|
cell_text = f"?{count_str}"
|
||||||
|
elif paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = "OK"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{expected}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"UNPAID {expected}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m
|
||||||
|
})
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = data["total_balance"]
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
# 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"])
|
||||||
|
import json
|
||||||
|
|
||||||
# Format unmatched
|
record_step("process_data")
|
||||||
unmatched = result["unmatched"]
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reconcile.html",
|
"reconcile-juniors.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"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
debts=debts,
|
debts=debts,
|
||||||
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")
|
||||||
def payments():
|
def payments():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
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"
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_payments")
|
||||||
|
|
||||||
# Group transactions by person
|
# Group transactions by person
|
||||||
grouped = {}
|
grouped = {}
|
||||||
@@ -151,6 +892,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 +901,42 @@ 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", "")
|
||||||
|
|
||||||
|
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
|
||||||
|
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
|
||||||
|
account = BANK_ACCOUNT
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if amt_val < 0 or amt_val > 10_000_000:
|
||||||
|
amt_val = 0
|
||||||
|
amt_str = f"{amt_val:.2f}"
|
||||||
|
except ValueError:
|
||||||
|
amt_str = "0.00"
|
||||||
|
|
||||||
|
# Message max 60 characters, strip SPD delimiters to prevent injection
|
||||||
|
msg_str = message[:60].replace("*", "")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir flask
|
RUN pip install --no-cache-dir \
|
||||||
|
flask \
|
||||||
|
google-api-python-client \
|
||||||
|
google-auth-httplib2 \
|
||||||
|
google-auth-oauthlib \
|
||||||
|
qrcode \
|
||||||
|
pillow \
|
||||||
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py Makefile ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
@@ -17,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 \
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "[entrypoint] Starting Flask app on port 5001..."
|
echo "[entrypoint] Starting gunicorn on port 5001..."
|
||||||
|
|
||||||
# Running the app directly via python
|
exec gunicorn \
|
||||||
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now.
|
--bind 0.0.0.0:5001 \
|
||||||
exec python3 /app/app.py
|
--workers "${GUNICORN_WORKERS:-2}" \
|
||||||
|
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||||
|
--access-logfile - \
|
||||||
|
app:app
|
||||||
|
|||||||
15
docs/README.md
Normal file
15
docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# FUJ Management Documentation
|
||||||
|
|
||||||
|
Welcome to the documentation for the FUJ Management application.
|
||||||
|
|
||||||
|
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Use the sidebar to explore the documentation:
|
||||||
|
|
||||||
|
* **[Project Notes](project-notes.md)**: Main brainstorming and domain model.
|
||||||
|
* **[Scripts](scripts.md)**: Details about available CLI tools.
|
||||||
|
* **[Fee Specification](fee-calculation-spec.md)**: Rules for fee calculation.
|
||||||
|
|
||||||
|
For more technical details, check out the guides by Claude and Gemini in the sidebar.
|
||||||
25
docs/_sidebar.md
Normal file
25
docs/_sidebar.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
* [Home](README.md)
|
||||||
|
* [Project Notes](project-notes.md)
|
||||||
|
* [Scripts](scripts.md)
|
||||||
|
* [Fee Spec](fee-calculation-spec.md)
|
||||||
|
|
||||||
|
* **By Claude Opus**
|
||||||
|
* [README](by-claude-opus/README.md)
|
||||||
|
* [User Guide](by-claude-opus/user-guide.md)
|
||||||
|
* [Web App](by-claude-opus/web-app.md)
|
||||||
|
* [Deployment](by-claude-opus/deployment.md)
|
||||||
|
* [Architecture](by-claude-opus/architecture.md)
|
||||||
|
* [Data Model](by-claude-opus/data-model.md)
|
||||||
|
* [Development](by-claude-opus/development.md)
|
||||||
|
* [Scripts](by-claude-opus/scripts.md)
|
||||||
|
* [Testing](by-claude-opus/testing.md)
|
||||||
|
|
||||||
|
* **By Gemini**
|
||||||
|
* [README](by-gemini/README.md)
|
||||||
|
* [User Guide](by-gemini/user-guide.md)
|
||||||
|
* [Architecture](by-gemini/architecture.md)
|
||||||
|
* [Deployment](by-gemini/deployment.md)
|
||||||
|
* [Scripts](by-gemini/scripts.md)
|
||||||
|
|
||||||
|
* **Specs**
|
||||||
|
* [Fio Sync](spec/fio_to_sheets_sync.md)
|
||||||
214
docs/by-claude-opus/README.md
Normal file
214
docs/by-claude-opus/README.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# FUJ Management — Comprehensive Documentation
|
||||||
|
|
||||||
|
> **FUJ = Frisbee Ultimate Jablonec** — a small sports club in the Czech Republic.
|
||||||
|
|
||||||
|
## What Is This Project?
|
||||||
|
|
||||||
|
FUJ Management is a purpose-built financial management system for a small ultimate frisbee club. It automates the tedious process of tracking **who attended practice**, **how much they owe**, **who has paid**, and **who still owes money** — a workflow that would otherwise require manual cross-referencing between attendance spreadsheets and bank statements.
|
||||||
|
|
||||||
|
The system is built around two Google Sheets (one for attendance, one for payments) and a Fio bank transparent account. A set of Python scripts sync and process the data, while a Flask-based web dashboard provides real-time visibility into fees, payments, and reconciliation status.
|
||||||
|
|
||||||
|
### The Problem It Solves
|
||||||
|
|
||||||
|
Before this system, the club treasurer had to:
|
||||||
|
|
||||||
|
1. **Manually count** attendance marks for each member each month
|
||||||
|
2. **Calculate** whether each person owes 0, 200, or 750 CZK based on how many times they showed up
|
||||||
|
3. **Cross-reference** bank statements to figure out who paid and for which month
|
||||||
|
4. **Chase** members who hadn't paid, often losing track of partial payments and advance payments
|
||||||
|
5. **Handle edge cases** like members paying for multiple months at once, using nicknames in payment messages, or paying via a family member's account
|
||||||
|
|
||||||
|
This system automates steps 1–4 entirely, and provides tooling for step 5.
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Attendance Sheet │ │ Fio Bank Account │
|
||||||
|
│ (Google Sheets) │ │ (transparent account) │
|
||||||
|
│ │ │ │
|
||||||
|
│ Members × Dates × ✓/✗ │ │ Incoming payments with │
|
||||||
|
│ Tier (A/J/X) │ │ sender, amount, message │
|
||||||
|
└──────────┬───────────────┘ └──────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
│ CSV export │ API / HTML scraping
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌───────────────────────┐
|
||||||
|
│ attendance.py │ │ sync_fio_to_sheets.py │
|
||||||
|
│ │ │ │
|
||||||
|
│ Fetches sheet, │ │ Syncs bank txns to │
|
||||||
|
│ computes fees │ │ Payments Google Sheet │
|
||||||
|
└────────┬────────┘ └───────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌───────────────────────┐
|
||||||
|
│ │ Payments Sheet │
|
||||||
|
│ │ (Google Sheets) │
|
||||||
|
│ │ │
|
||||||
|
│ │ Date|Amount|Person| │
|
||||||
|
│ │ Purpose|Sender|etc. │
|
||||||
|
│ └───────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ┌──────────────┐ ┌──────────────────┐
|
||||||
|
│ │infer_payments│ │ match_payments.py │
|
||||||
|
│ │ .py │ │ │
|
||||||
|
│ │ │ │ Reconciliation │
|
||||||
|
│ │ Auto-fills │ │ engine: matches │
|
||||||
|
│ │ Person, │ │ payments against │
|
||||||
|
│ │ Purpose, │ │ expected fees │
|
||||||
|
│ │ Amount │ └────────┬──────────┘
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Flask Web App │
|
||||||
|
│ (app.py) │
|
||||||
|
│ │
|
||||||
|
│ /fees – fee │
|
||||||
|
│ table │
|
||||||
|
│ /reconcile – balance │
|
||||||
|
│ matrix │
|
||||||
|
│ /payments – ledger │
|
||||||
|
│ /qr – QR code │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.13+**
|
||||||
|
- **[uv](https://docs.astral.sh/uv/)** — fast Python package manager
|
||||||
|
- **Google Sheets API credentials** — a service account JSON file placed at `.secret/fuj-management-bot-credentials.json`
|
||||||
|
- *Optional*: `FIO_API_TOKEN` environment variable for Fio REST API access (falls back to transparent page scraping)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install dependencies
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
uv sync # Installs all dependencies from pyproject.toml
|
||||||
|
|
||||||
|
# Place your Google API credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
cp /path/to/your/credentials.json .secret/fuj-management-bot-credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Operations
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `make web` | Start the web dashboard at `http://localhost:5001` |
|
||||||
|
| `make sync` | Pull new bank transactions into the Google Sheet |
|
||||||
|
| `make infer` | Auto-fill Person/Purpose/Amount for new transactions |
|
||||||
|
| `make reconcile` | Print a CLI balance report |
|
||||||
|
| `make fees` | Print fee calculation table from attendance |
|
||||||
|
| `make test` | Run the test suite |
|
||||||
|
| `make image` | Build the Docker container image |
|
||||||
|
|
||||||
|
### Typical Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
make sync → make infer → (manual review in Google Sheets) → make web
|
||||||
|
↓ ↓ ↓ ↓
|
||||||
|
Pull new bank Auto-match Fix any [?] View live
|
||||||
|
transactions payments to flagged rows dashboard
|
||||||
|
into sheet members/months in the sheet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|----------|----------|
|
||||||
|
| [Architecture](architecture.md) | System design, data flow diagrams, module dependency graph |
|
||||||
|
| [Web Application](web-app.md) | Flask app architecture, routes, templates, interactive features |
|
||||||
|
| [User Guide](user-guide.md) | End-user guide for the web dashboard — what each page shows |
|
||||||
|
| [Scripts Reference](scripts.md) | Detailed reference for all CLI scripts and shared modules |
|
||||||
|
| [Data Model](data-model.md) | Google Sheets schemas, fee calculation rules, bank integration |
|
||||||
|
| [Deployment](deployment.md) | Docker containerization, Gitea CI/CD, Kubernetes deployment |
|
||||||
|
| [Testing](testing.md) | Test infrastructure, coverage, how to write new tests |
|
||||||
|
| [Development Guide](development.md) | Local setup, coding conventions, tooling, project history |
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Language | Python 3.13+ |
|
||||||
|
| Web framework | Flask 3.1 |
|
||||||
|
| Package management | uv + pyproject.toml |
|
||||||
|
| Data sources | Google Sheets API, Fio Bank API / HTML scraping |
|
||||||
|
| QR codes | `qrcode` library (PIL backend) |
|
||||||
|
| Containerization | Docker (Alpine-based) |
|
||||||
|
| CI/CD | Gitea Actions |
|
||||||
|
| Deployment target | Self-hosted Kubernetes |
|
||||||
|
| Frontend | Server-rendered HTML/CSS/JS (terminal-aesthetic theme) |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
fuj-management/
|
||||||
|
├── app.py # Flask web application (4 routes)
|
||||||
|
├── Makefile # Build automation (13 targets)
|
||||||
|
├── pyproject.toml # Python dependencies and metadata
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── attendance.py # Shared: attendance data + fee calculation
|
||||||
|
│ ├── calculate_fees.py # CLI: print fee table
|
||||||
|
│ ├── match_payments.py # Core: reconciliation engine + CLI report
|
||||||
|
│ ├── infer_payments.py # Auto-fill Person/Purpose in Google Sheet
|
||||||
|
│ ├── sync_fio_to_sheets.py # Sync Fio bank → Google Sheet
|
||||||
|
│ ├── fio_utils.py # Shared: Fio bank data fetching
|
||||||
|
│ └── czech_utils.py # Shared: diacritics normalization + Czech month parsing
|
||||||
|
│
|
||||||
|
├── templates/
|
||||||
|
│ ├── fees.html # Attendance/fees dashboard
|
||||||
|
│ ├── reconcile.html # Payment reconciliation with modals + QR
|
||||||
|
│ └── payments.html # Payments ledger grouped by member
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_app.py # Flask route tests (mocked data)
|
||||||
|
│ └── test_reconcile_exceptions.py # Reconciliation with fee exceptions
|
||||||
|
│
|
||||||
|
├── build/
|
||||||
|
│ ├── Dockerfile # Alpine-based container image
|
||||||
|
│ └── entrypoint.sh # Container entry point
|
||||||
|
│
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── build.yaml # CI: build + push Docker image
|
||||||
|
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
|
||||||
|
│
|
||||||
|
├── .secret/ # (gitignored) API credentials
|
||||||
|
├── docs/ # Project documentation
|
||||||
|
│ ├── project-notes.md # Original brainstorming and design notes
|
||||||
|
│ ├── fee-calculation-spec.md # Fee rules and payment matching spec
|
||||||
|
│ ├── scripts.md # Legacy scripts documentation
|
||||||
|
│ └── spec/
|
||||||
|
│ └── fio_to_sheets_sync.md # Fio-to-Sheets sync specification
|
||||||
|
│
|
||||||
|
└── CLAUDE.md # AI assistant context file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **No database** — Google Sheets serves as both the data store and the manual editing interface. This keeps the system simple and accessible to non-technical club members who can review and edit data directly in the spreadsheet.
|
||||||
|
|
||||||
|
2. **PII separation** — No member names or personal data are stored in the git repository. All data is fetched at runtime from Google Sheets and the bank account.
|
||||||
|
|
||||||
|
3. **Idempotent sync** — The Fio-to-Sheets sync uses SHA-256 hashes as deduplication keys, making re-runs safe and append-only.
|
||||||
|
|
||||||
|
4. **Graceful fallbacks** — Bank data can be fetched via the REST API (if a token is available) or by scraping the public transparent account page. The system doesn't break if the API token is missing.
|
||||||
|
|
||||||
|
5. **Czech language support** — Payment messages are in Czech and use diacritics. The system normalizes text (strips diacritics) and understands Czech month names in all grammatical declensions.
|
||||||
|
|
||||||
|
6. **Terminal aesthetic** — The web dashboard uses a monospace, dark-themed, terminal-inspired design that matches the project's pragmatic, CLI-first philosophy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This documentation was generated on 2026-03-03 by Claude Opus, based on a comprehensive analysis of the complete codebase.*
|
||||||
268
docs/by-claude-opus/architecture.md
Normal file
268
docs/by-claude-opus/architecture.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FUJ Management follows a **pipeline architecture** where data flows from external sources (Google Sheets, Fio Bank) through processing scripts into a web dashboard. There is no central database — Google Sheets serves as the persistent data store, and the Flask app renders views by fetching and processing data on every request.
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ EXTERNAL DATA SOURCES │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Attendance Sheet │ │ Fio Bank Account │ │
|
||||||
|
│ │ (Google Sheets) │ │ │ │
|
||||||
|
│ │ │ │ ┌────────────────┐ │ │
|
||||||
|
│ │ ID: 1E2e_gT... │ │ │ REST API │ │ │
|
||||||
|
│ │ │ │ │ (JSON, w/token)│ │ │
|
||||||
|
│ │ CSV export (pub) │ │ ├────────────────┤ │ │
|
||||||
|
│ │ │ │ │ Transparent │ │ │
|
||||||
|
│ └────────┬─────────┘ │ │ page (HTML) │ │ │
|
||||||
|
│ │ │ └───────┬────────┘ │ │
|
||||||
|
│ │ └──────────┼──────────┘ │
|
||||||
|
└───────────┼───────────────────────┼────────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ ─ DATA INGESTION ─ ┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
┌───────────▼──────┐ ┌───────────▼──────────┐
|
||||||
|
│ attendance.py │ │ fio_utils.py │
|
||||||
|
│ │ │ │
|
||||||
|
│ fetch_csv() │ │ fetch_transactions() │
|
||||||
|
│ parse_dates() │ │ FioTableParser │
|
||||||
|
│ group_by_month() │ │ parse_czech_amount() │
|
||||||
|
│ calculate_fee() │ │ parse_czech_date() │
|
||||||
|
│ get_members() │ │ │
|
||||||
|
│ get_members_ │ │ API + HTML fallback │
|
||||||
|
│ with_fees() │ │ │
|
||||||
|
└───────────┬──────┘ └───────────┬──────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ ─ PROCESSING ─ ─ ─ ┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────▼──────────┐
|
||||||
|
│ │ sync_fio_to_sheets.py │ ──▶ Payments Sheet
|
||||||
|
│ │ │ (Google Sheets)
|
||||||
|
│ │ generate_sync_id() │
|
||||||
|
│ │ sort_sheet_by_date() │
|
||||||
|
│ │ get_sheets_service() │
|
||||||
|
│ └────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────▼──────────┐
|
||||||
|
│ │ infer_payments.py │ ──▶ Writes back to
|
||||||
|
│ │ │ Payments Sheet
|
||||||
|
│ │ infer Person/Purpose/ │
|
||||||
|
│ │ Amount for empty rows │
|
||||||
|
│ └────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────▼──────────┐
|
||||||
|
│ │ czech_utils.py │
|
||||||
|
│ │ │
|
||||||
|
│ │ normalize() — strip │
|
||||||
|
│ │ diacritics │
|
||||||
|
│ │ parse_month_references() │
|
||||||
|
│ │ CZECH_MONTHS dict │
|
||||||
|
│ └─────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ RECONCILIATION ─ ─┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
┌─────────▼───────────────────────▼───────────┐
|
||||||
|
│ match_payments.py │
|
||||||
|
│ │
|
||||||
|
│ _build_name_variants() — name matching │
|
||||||
|
│ match_members() — fuzzy match │
|
||||||
|
│ infer_transaction_details() │
|
||||||
|
│ fetch_sheet_data() — read payments │
|
||||||
|
│ fetch_exceptions() — fee overrides │
|
||||||
|
│ reconcile() — CORE ENGINE │
|
||||||
|
│ print_report() — CLI output │
|
||||||
|
└──────────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
─ ─ ─ ─ ─ ─ ─ PRESENTATION ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
│
|
||||||
|
┌──────────────────────▼──────────────────────┐
|
||||||
|
│ app.py (Flask) │
|
||||||
|
│ │
|
||||||
|
│ GET / → redirect to /fees │
|
||||||
|
│ GET /fees → fees.html │
|
||||||
|
│ GET /reconcile → reconcile.html │
|
||||||
|
│ GET /payments → payments.html │
|
||||||
|
│ GET /qr → PNG QR code (SPD format) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
app.py
|
||||||
|
├── attendance.py
|
||||||
|
│ └── (stdlib: csv, urllib, datetime)
|
||||||
|
└── match_payments.py
|
||||||
|
├── attendance.py
|
||||||
|
├── czech_utils.py
|
||||||
|
│ └── (stdlib: re, unicodedata)
|
||||||
|
└── sync_fio_to_sheets.py (for get_sheets_service, DEFAULT_SPREADSHEET_ID)
|
||||||
|
└── fio_utils.py
|
||||||
|
└── (stdlib: json, urllib, html.parser, datetime)
|
||||||
|
|
||||||
|
infer_payments.py
|
||||||
|
├── sync_fio_to_sheets.py
|
||||||
|
├── match_payments.py
|
||||||
|
└── attendance.py
|
||||||
|
|
||||||
|
calculate_fees.py
|
||||||
|
└── attendance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Relationships
|
||||||
|
|
||||||
|
| Module | Imports from |
|
||||||
|
|--------|-------------|
|
||||||
|
| `app.py` | `attendance` (`get_members_with_fees`, `SHEET_ID`), `match_payments` (`reconcile`, `fetch_sheet_data`, `fetch_exceptions`, `normalize`, `DEFAULT_SPREADSHEET_ID`) |
|
||||||
|
| `match_payments.py` | `attendance` (`get_members_with_fees`), `czech_utils` (`normalize`, `parse_month_references`), `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`) |
|
||||||
|
| `infer_payments.py` | `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`), `match_payments` (`infer_transaction_details`), `attendance` (`get_members_with_fees`) |
|
||||||
|
| `sync_fio_to_sheets.py` | `fio_utils` (`fetch_transactions`) |
|
||||||
|
| `calculate_fees.py` | `attendance` (`get_members_with_fees`) |
|
||||||
|
|
||||||
|
## Data Flow Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Sync & Enrich (Batch Pipeline)
|
||||||
|
|
||||||
|
This is the primary workflow for keeping the payments ledger up to date:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. make sync 2. make infer
|
||||||
|
┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Fio │───▶│ Payments │ │ Payments │───▶│ Payments │
|
||||||
|
│ Bank │ │ Sheet │ │ Sheet │ │ Sheet │
|
||||||
|
└──────┘ │ (append) │ │ (read) │ │ (update) │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
|
|
||||||
|
- Fetches last 30 days - Reads empty Person/Purpose rows
|
||||||
|
- SHA-256 dedup prevents - Uses name matching + Czech month
|
||||||
|
duplicate entries parsing to auto-fill
|
||||||
|
- Marks uncertain matches with [?]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Real-Time Rendering (Web Dashboard)
|
||||||
|
|
||||||
|
Every web request triggers a fresh data fetch — no caching layer exists:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Request → Flask Route → Fetch (Google Sheets API/CSV) → Process → Render HTML
|
||||||
|
│ │
|
||||||
|
│ attendance.py │ reconcile()
|
||||||
|
│ fetch_sheet_data() │ or direct
|
||||||
|
│ fetch_exceptions() │ formatting
|
||||||
|
▼ ▼
|
||||||
|
~1-3 seconds Template with
|
||||||
|
(network I/O) inline CSS + JS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: QR Code Generation (On-Demand)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser clicks "Pay" → GET /qr?account=...&amount=...&message=... → SPD QR PNG
|
||||||
|
│
|
||||||
|
qrcode lib
|
||||||
|
generates
|
||||||
|
in-memory PNG
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
### 1. Google Sheets as Database
|
||||||
|
|
||||||
|
Instead of a traditional database, the system uses two Google Sheets:
|
||||||
|
|
||||||
|
| Sheet | Purpose | Access Method |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
| Attendance Sheet (`1E2e_gT...`) | Member names, tiers, practice dates, attendance marks | Public CSV export (no auth needed) |
|
||||||
|
| Payments Sheet (`1Om0YPo...`) | Bank transactions with Person/Purpose annotations | Google Sheets API (service account auth) |
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- ✅ Non-technical users can view and edit data directly
|
||||||
|
- ✅ No database setup or maintenance
|
||||||
|
- ✅ Built-in audit trail (Google Sheets version history)
|
||||||
|
- ❌ Every page load incurs 1-3s of API latency
|
||||||
|
- ❌ No complex queries or indexing
|
||||||
|
- ❌ Rate limits on Google Sheets API
|
||||||
|
|
||||||
|
### 2. Dual-Mode Bank Access
|
||||||
|
|
||||||
|
`fio_utils.py` implements a transparent fallback pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fetch_transactions(date_from, date_to):
|
||||||
|
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||||
|
if token:
|
||||||
|
return fetch_transactions_api(token, date_from, date_to) # Structured JSON
|
||||||
|
return fetch_transactions_transparent(...) # HTML scraping
|
||||||
|
```
|
||||||
|
|
||||||
|
The API provides richer data (sender account numbers, stable bank IDs) but requires a token. The transparent page is always available but lacks some fields.
|
||||||
|
|
||||||
|
### 3. Name Matching with Confidence Levels
|
||||||
|
|
||||||
|
The reconciliation engine uses a multi-tier matching strategy:
|
||||||
|
|
||||||
|
| Priority | Method | Confidence | Example |
|
||||||
|
|----------|--------|-----------|---------|
|
||||||
|
| 1 | Full name match | `auto` | "František Vrbík" in message |
|
||||||
|
| 2 | Both first + last name (any order) | `auto` | "Vrbík František" |
|
||||||
|
| 3 | Nickname match | `auto` | "(Štrúdl)" from member list |
|
||||||
|
| 4 | Last name only (≥4 chars, not common) | `review` | "Vrbík" alone |
|
||||||
|
| 5 | First name only (≥3 chars) | `review` | "František" alone |
|
||||||
|
|
||||||
|
When both `auto` and `review` matches exist, `review` matches are discarded. This prevents false positives from generic first names.
|
||||||
|
|
||||||
|
### 4. Exception System
|
||||||
|
|
||||||
|
Fee overrides are managed through an `exceptions` sheet tab in the Payments Google Sheet:
|
||||||
|
|
||||||
|
| Column | Content |
|
||||||
|
|--------|---------|
|
||||||
|
| Name | Member name |
|
||||||
|
| Period | Month (YYYY-MM) |
|
||||||
|
| Amount | Overridden fee in CZK |
|
||||||
|
| Note | Reason for the exception |
|
||||||
|
|
||||||
|
Exceptions are applied during reconciliation, replacing the attendance-calculated fee with the manually specified amount.
|
||||||
|
|
||||||
|
### 5. Render-Time Performance Tracking
|
||||||
|
|
||||||
|
Every page includes a performance breakdown:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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()))
|
||||||
|
```
|
||||||
|
|
||||||
|
The footer displays total render time and, on click, reveals a detailed breakdown (e.g., `fetch_members:0.892s | fetch_payments:1.205s | reconcile:0.003s | render:0.015s`).
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
| Concern | Mitigation |
|
||||||
|
|---------|-----------|
|
||||||
|
| PII in git | `.secret/` is gitignored; all data fetched at runtime |
|
||||||
|
| Google API credentials | Service account JSON stored in `.secret/`, mounted as Docker secret |
|
||||||
|
| Bank API token | Passed via `FIO_API_TOKEN` environment variable, never committed |
|
||||||
|
| Web app authentication | **None currently** — the app has no auth layer |
|
||||||
|
| CSRF protection | **None currently** — Flask default (no POST routes exist) |
|
||||||
|
|
||||||
|
## Scalability Notes
|
||||||
|
|
||||||
|
This system is purpose-built for a small club (~20-40 members). It makes deliberate trade-offs favoring simplicity over scale:
|
||||||
|
|
||||||
|
- **No caching**: Every page load fetches live data from Google Sheets (1-3s latency). For a single-user admin dashboard, this is acceptable.
|
||||||
|
- **No background workers**: Sync and inference are manual `make` commands, not scheduled jobs.
|
||||||
|
- **No database**: Google Sheets handles 10s of members and 100s of transactions with ease.
|
||||||
|
- **Single-process Flask**: The built-in development server runs directly in production (via Docker). For this use case, this is intentional — it's a personal tool, not a public service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
201
docs/by-claude-opus/data-model.md
Normal file
201
docs/by-claude-opus/data-model.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Data Model
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FUJ Management operates on two Google Sheets and an external bank account. There is no local database — all persistent data lives in Google Sheets, and all member data is fetched at runtime (never committed to git).
|
||||||
|
|
||||||
|
## External Data Sources
|
||||||
|
|
||||||
|
### 1. Attendance Google Sheet
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Sheet ID** | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` |
|
||||||
|
| **Access** | Public CSV export (no authentication required) |
|
||||||
|
| **Purpose** | Member roster, weekly practice attendance marks |
|
||||||
|
| **Scope** | Tuesday practices (20:30–22:00) |
|
||||||
|
|
||||||
|
#### Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
Row 1: [Title] [blank] [blank] [10/1/2025] [10/8/2025] [10/15/2025] ...
|
||||||
|
Row 2: Venue per date (ignored by the system)
|
||||||
|
Row 3: Subtotals per date (ignored by the system)
|
||||||
|
Row 4+: [Name] [Tier] [Total] [TRUE/FALSE] [TRUE/FALSE] ...
|
||||||
|
...
|
||||||
|
Row N: # last line (sentinel — stops parsing)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Column | Index | Content | Example |
|
||||||
|
|--------|-------|---------|---------|
|
||||||
|
| A | 0 | Member name | `Jan Novák` |
|
||||||
|
| B | 1 | Tier code | `A`, `J`, or `X` |
|
||||||
|
| C | 2 | Total attendance (auto-calculated, ignored by the system) | `12` |
|
||||||
|
| D+ | 3+ | Attendance per date | `TRUE` or `FALSE` |
|
||||||
|
|
||||||
|
#### Tier Codes
|
||||||
|
|
||||||
|
| Code | Meaning | Pays fees? |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `A` | Adult | Yes — calculated from this sheet |
|
||||||
|
| `J` | Junior | No — managed via a separate sheet |
|
||||||
|
| `X` | Exempt | No |
|
||||||
|
|
||||||
|
#### Sentinel Row
|
||||||
|
|
||||||
|
The system stops parsing member rows when it encounters a row whose first column contains `# last line` (case-insensitive). Rows starting with `#` are also skipped as comments.
|
||||||
|
|
||||||
|
### 2. Payments Google Sheet
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Sheet ID** | `1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y` |
|
||||||
|
| **Access** | Google Sheets API (service account authentication) |
|
||||||
|
| **Purpose** | Intermediary ledger for bank transactions + manual annotations |
|
||||||
|
| **Managed by** | `sync_fio_to_sheets.py` (append), `infer_payments.py` (update) |
|
||||||
|
|
||||||
|
#### Main Sheet Schema (Columns A–K)
|
||||||
|
|
||||||
|
| Column | Label | Populated by | Description |
|
||||||
|
|--------|-------|-------------|-------------|
|
||||||
|
| A | Date | `sync` | Transaction date (`YYYY-MM-DD`) |
|
||||||
|
| B | Amount | `sync` | Bank transaction amount in CZK |
|
||||||
|
| C | manual fix | Human | If non-empty, `infer` will skip this row |
|
||||||
|
| D | Person | `infer` or human | Member name(s), comma-separated for multi-person payments |
|
||||||
|
| E | Purpose | `infer` or human | Month(s) covered, e.g. `2026-01` or `2026-01, 2026-02` |
|
||||||
|
| F | Inferred Amount | `infer` or human | Amount to use for reconciliation (may differ from bank amount) |
|
||||||
|
| G | Sender | `sync` | Bank sender name/account |
|
||||||
|
| H | VS | `sync` | Variable symbol |
|
||||||
|
| I | Message | `sync` | Payment message for recipient |
|
||||||
|
| J | Bank ID | `sync` | Fio transaction ID (API only) |
|
||||||
|
| K | Sync ID | `sync` | SHA-256 deduplication hash |
|
||||||
|
|
||||||
|
#### Exceptions Sheet Tab
|
||||||
|
|
||||||
|
A separate tab named `exceptions` in the same spreadsheet, used for manual fee overrides:
|
||||||
|
|
||||||
|
| Column | Label | Content |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| A | Name | Member name (plain text) |
|
||||||
|
| B | Period | Month (`YYYY-MM`) |
|
||||||
|
| C | Amount | Overridden fee in CZK |
|
||||||
|
| D | Note | Reason for override (optional) |
|
||||||
|
|
||||||
|
The first row is assumed to be a header and is skipped. Name and period values are normalized (diacritics stripped, lowercased) for matching.
|
||||||
|
|
||||||
|
### 3. Fio Bank Account
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Account number** | `2800359168/2010` |
|
||||||
|
| **IBAN** | `CZ8520100000002800359168` |
|
||||||
|
| **Type** | Transparent account |
|
||||||
|
| **Owner** | Nathan Heilmann |
|
||||||
|
| **Public URL** | `https://ib.fio.cz/ib/transparent?a=2800359168` |
|
||||||
|
|
||||||
|
#### Access Methods
|
||||||
|
|
||||||
|
| Method | Trigger | Data richness |
|
||||||
|
|--------|---------|--------------|
|
||||||
|
| REST API | `FIO_API_TOKEN` env var set | Full data: sender account, bank ID, user identification, currency |
|
||||||
|
| HTML scraping | `FIO_API_TOKEN` not set | Partial: date, amount, sender name, message, VS/KS/SS |
|
||||||
|
|
||||||
|
#### API Rate Limit
|
||||||
|
|
||||||
|
The Fio REST API allows 1 request per 30 seconds per token.
|
||||||
|
|
||||||
|
## Fee Calculation Rules
|
||||||
|
|
||||||
|
Fees apply only to **tier A (Adult)** members. They are calculated per calendar month based on Tuesday practice attendance:
|
||||||
|
|
||||||
|
| Practices attended | Monthly fee |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| 0 | 0 CZK |
|
||||||
|
| 1 | 200 CZK |
|
||||||
|
| 2+ | 750 CZK |
|
||||||
|
|
||||||
|
### Exception Overrides
|
||||||
|
|
||||||
|
The fee can be manually overridden per member per month via the `exceptions` tab. When an exception exists:
|
||||||
|
- The `expected` amount in reconciliation uses the exception amount
|
||||||
|
- The `original_expected` amount preserves the attendance-based calculation
|
||||||
|
- The override is displayed in amber/orange in the web UI
|
||||||
|
|
||||||
|
### Advance Payments
|
||||||
|
|
||||||
|
If a payment references a month not yet covered by attendance data:
|
||||||
|
- It is tracked as **credit** on the member's account
|
||||||
|
- Credits are added to the total balance
|
||||||
|
- When attendance data becomes available for that month, the credit effectively offsets the expected fee
|
||||||
|
|
||||||
|
## Reconciliation Data Model
|
||||||
|
|
||||||
|
The `reconcile()` function returns this structure:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"members": {
|
||||||
|
"Jan Novák": {
|
||||||
|
"tier": "A",
|
||||||
|
"months": {
|
||||||
|
"2026-01": {
|
||||||
|
"expected": 750, # Fee after exception application
|
||||||
|
"original_expected": 750, # Attendance-based fee
|
||||||
|
"attendance_count": 4, # How many times they came
|
||||||
|
"exception": None, # or {"amount": 400, "note": "..."}
|
||||||
|
"paid": 750.0, # Total matched payments
|
||||||
|
"transactions": [ # Individual payment records
|
||||||
|
{
|
||||||
|
"amount": 750.0,
|
||||||
|
"date": "2026-01-15",
|
||||||
|
"sender": "Jan Novák",
|
||||||
|
"message": "leden",
|
||||||
|
"confidence": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_balance": 0 # sum(paid - expected) across all months + off-window credits
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unmatched": [ # Transactions that couldn't be assigned
|
||||||
|
{
|
||||||
|
"date": "2026-01-20",
|
||||||
|
"amount": 500,
|
||||||
|
"sender": "Unknown",
|
||||||
|
"message": "dar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credits": { # Alias for positive total_balance entries
|
||||||
|
"Jan Novák": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync ID Generation
|
||||||
|
|
||||||
|
The deduplication key for bank transactions is a SHA-256 hash of:
|
||||||
|
|
||||||
|
```
|
||||||
|
sha256("date|amount|currency|sender|vs|message|bank_id")
|
||||||
|
```
|
||||||
|
|
||||||
|
All values are lowercased before hashing. This ensures:
|
||||||
|
- Same transaction fetched twice produces the same ID
|
||||||
|
- Two payments on the same day with different amounts/senders produce different IDs
|
||||||
|
- The hash is stable across API and HTML scraping modes (shared fields)
|
||||||
|
|
||||||
|
## Date Handling
|
||||||
|
|
||||||
|
| Source | Format | Normalization |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| Attendance Sheet header | `M/D/YYYY` (US format) | `datetime.strptime(raw, "%m/%d/%Y")` |
|
||||||
|
| Fio API | `YYYY-MM-DD+HHMM` | Take first 10 characters |
|
||||||
|
| Fio transparent page | `DD.MM.YYYY` | `datetime.strptime(raw, "%d.%m.%Y")` |
|
||||||
|
| Google Sheets (unformatted) | Serial number (days since 1899-12-30) | `datetime(1899, 12, 30) + timedelta(days=val)` |
|
||||||
|
|
||||||
|
All internal date representation uses `YYYY-MM-DD` format. Month keys use `YYYY-MM`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Data model documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
198
docs/by-claude-opus/deployment.md
Normal file
198
docs/by-claude-opus/deployment.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.13+** (required by `pyproject.toml`)
|
||||||
|
- **[uv](https://docs.astral.sh/uv/)** — Fast Python package manager
|
||||||
|
- Google Sheets API credentials (service account JSON)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Configure credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
cp /path/to/credentials.json .secret/fuj-management-bot-credentials.json
|
||||||
|
|
||||||
|
# Optional: Set Fio API token for richer bank data
|
||||||
|
export FIO_API_TOKEN=your_token_here
|
||||||
|
|
||||||
|
# Start the web dashboard
|
||||||
|
make web
|
||||||
|
# → Flask server at http://localhost:5001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Makefile Targets
|
||||||
|
|
||||||
|
| Target | Command | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `help` | `make help` | List all available targets |
|
||||||
|
| `venv` | `make venv` | Sync virtual environment with pyproject.toml |
|
||||||
|
| `fees` | `make fees` | Print fee calculation table |
|
||||||
|
| `match` | `make match` | (Legacy) Direct bank matching |
|
||||||
|
| `web` | `make web` | Start Flask dashboard on port 5001 |
|
||||||
|
| `sync` | `make sync` | Sync last 30 days of bank transactions |
|
||||||
|
| `sync-2026` | `make sync-2026` | Sync full year 2026 transactions |
|
||||||
|
| `infer` | `make infer` | Auto-fill Person/Purpose in the sheet |
|
||||||
|
| `reconcile` | `make reconcile` | Print CLI balance report |
|
||||||
|
| `test` | `make test` | Run test suite |
|
||||||
|
| `test-v` | `make test-v` | Run tests with verbose output |
|
||||||
|
| `image` | `make image` | Build Docker image |
|
||||||
|
| `run` | `make run` | Run Docker container locally |
|
||||||
|
|
||||||
|
The Makefile includes **automatic venv management**: targets that need Python depend on `.venv/.last_sync`, which triggers `uv sync` when `pyproject.toml` changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Container
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make image
|
||||||
|
# → docker build -t fuj-management:latest -f build/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dockerfile Details
|
||||||
|
|
||||||
|
**Base image**: `python:3.13-alpine`
|
||||||
|
|
||||||
|
**Build stages**:
|
||||||
|
1. Install system packages (`bash`, `tzdata`)
|
||||||
|
2. Set timezone to `Europe/Prague`
|
||||||
|
3. Install Python dependencies via pip
|
||||||
|
4. Copy application files (`app.py`, `scripts/`, `templates/`, `Makefile`)
|
||||||
|
5. Copy entrypoint script
|
||||||
|
|
||||||
|
**Exposed port**: 5001
|
||||||
|
|
||||||
|
**Health check**: `wget -q -O /dev/null http://localhost:5001/` every 60s
|
||||||
|
|
||||||
|
### Running Locally via Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
# → docker run -it --rm -p 5001:5001 fuj-management:latest
|
||||||
|
|
||||||
|
# With credentials and environment:
|
||||||
|
docker run -it --rm \
|
||||||
|
-p 5001:5001 \
|
||||||
|
-v $(pwd)/.secret:/app/.secret:ro \
|
||||||
|
-e FIO_API_TOKEN=your_token \
|
||||||
|
-e BANK_ACCOUNT=CZ8520100000002800359168 \
|
||||||
|
fuj-management:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
|
||||||
|
The `build/entrypoint.sh` script simply runs:
|
||||||
|
```bash
|
||||||
|
exec python3 /app/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Flask's built-in server directly. For a production deployment, consider adding gunicorn or waitress (noted as a TODO in the entrypoint).
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | IBAN for QR code generation |
|
||||||
|
| `FIO_API_TOKEN` | *(none)* | Fio REST API token |
|
||||||
|
| `PYTHONUNBUFFERED` | `1` (set in Dockerfile) | Ensures real-time log output |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### Gitea Actions
|
||||||
|
|
||||||
|
The project uses two Gitea Actions workflows:
|
||||||
|
|
||||||
|
#### 1. Build and Push (`build.yaml`)
|
||||||
|
|
||||||
|
**Triggers**:
|
||||||
|
- Push of any tag
|
||||||
|
- Manual dispatch (with custom tag input)
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Checkout code
|
||||||
|
2. Login to Gitea container registry (`gitea.home.hrajfrisbee.cz`)
|
||||||
|
3. Build Docker image using `build/Dockerfile`
|
||||||
|
4. Push to `gitea.home.hrajfrisbee.cz/<owner>/<repo>:<tag>`
|
||||||
|
|
||||||
|
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
||||||
|
|
||||||
|
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
|
||||||
|
|
||||||
|
**Triggers**:
|
||||||
|
- Push to any branch
|
||||||
|
- Manual dispatch
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Checkout code
|
||||||
|
2. Install kubectl
|
||||||
|
3. Retrieve Kanidm token from HashiCorp Vault:
|
||||||
|
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
||||||
|
- Fetch API token from `secret/data/gitea/gitea-ci`
|
||||||
|
4. Exchange API token for K8s OIDC token via Kanidm:
|
||||||
|
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
||||||
|
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
||||||
|
5. Configure kubectl with the OIDC token
|
||||||
|
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
||||||
|
|
||||||
|
**Required secrets**:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||||
|
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
||||||
|
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
||||||
|
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
||||||
|
|
||||||
|
### Infrastructure Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea (git push / tag)
|
||||||
|
│
|
||||||
|
├── build.yaml → Docker Build → Gitea Container Registry
|
||||||
|
│ (gitea.home.hrajfrisbee.cz)
|
||||||
|
│
|
||||||
|
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
||||||
|
(192.168.0.31:6443)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a self-hosted infrastructure stack:
|
||||||
|
- **Gitea** for git hosting and CI/CD
|
||||||
|
- **HashiCorp Vault** for secret management
|
||||||
|
- **Kanidm** for identity/OIDC
|
||||||
|
- **Kubernetes** for container orchestration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials Management
|
||||||
|
|
||||||
|
### Google Sheets API
|
||||||
|
|
||||||
|
The system uses a **Google Cloud service account** for accessing the Payments Google Sheet. The credentials file must be:
|
||||||
|
- Stored at `.secret/fuj-management-bot-credentials.json`
|
||||||
|
- In Google Cloud service account JSON format
|
||||||
|
- The service account must be shared (as editor) on the target Google Sheet
|
||||||
|
|
||||||
|
For local development with OAuth2 (personal Google account), the system also supports the OAuth2 installed app flow — it will generate a `token.pickle` file on first use.
|
||||||
|
|
||||||
|
### Fio Bank API
|
||||||
|
|
||||||
|
Optional. Set the `FIO_API_TOKEN` environment variable. The token is generated in Fio internetbanking under Settings → API.
|
||||||
|
|
||||||
|
**Rate limit**: 1 request per 30 seconds per token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Deployment documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
228
docs/by-claude-opus/development.md
Normal file
228
docs/by-claude-opus/development.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
### Required Tools
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Python | 3.13+ | Runtime |
|
||||||
|
| uv | Latest | Dependency management |
|
||||||
|
| Docker | Latest | Container builds |
|
||||||
|
| Git | Any | Version control |
|
||||||
|
| Make | Any | Build automation |
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
|
||||||
|
# 2. Install dependencies (creates .venv automatically)
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# 3. Activate the virtual environment
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# 4. Set up credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
# Copy your Google service account JSON here:
|
||||||
|
cp ~/Downloads/fuj-management-bot-credentials.json .secret/
|
||||||
|
|
||||||
|
# 5. (Optional) Set Fio API token
|
||||||
|
export FIO_API_TOKEN=your_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### IDE Configuration
|
||||||
|
|
||||||
|
The `.vscode/` directory contains workspace settings. If using VS Code, the Python interpreter should automatically detect the `.venv` directory.
|
||||||
|
|
||||||
|
**PYTHONPATH note**: When running scripts from the project root, the Makefile sets `PYTHONPATH=scripts:$PYTHONPATH`. If your IDE doesn't do this, you may see import errors in `match_payments.py` and other scripts that import sibling modules.
|
||||||
|
|
||||||
|
## Project Dependencies
|
||||||
|
|
||||||
|
Defined in `pyproject.toml`:
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `flask` | ≥3.1.3 | Web framework |
|
||||||
|
| `google-api-python-client` | ≥2.162.0 | Google Sheets API |
|
||||||
|
| `google-auth-httplib2` | ≥0.2.0 | Google auth transport |
|
||||||
|
| `google-auth-oauthlib` | ≥1.2.1 | OAuth2 support |
|
||||||
|
| `qrcode[pil]` | ≥8.0 | QR code generation (with PIL/Pillow backend) |
|
||||||
|
|
||||||
|
The project uses `uv` with `package = false` in `[tool.uv]`, meaning it's not an installable package — dependencies are synced directly to the virtual environment.
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
### Python Style
|
||||||
|
|
||||||
|
- No linter or formatter is configured — the codebase uses a pragmatic, readable style
|
||||||
|
- Type hints are used for function signatures but not exhaustively
|
||||||
|
- Docstrings follow Google-style format on key functions
|
||||||
|
- Scripts use `if __name__ == "__main__": main()` pattern
|
||||||
|
|
||||||
|
### Import Pattern
|
||||||
|
|
||||||
|
Scripts in the `scripts/` directory import from each other as top-level modules:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In match_payments.py:
|
||||||
|
from attendance import get_members_with_fees
|
||||||
|
from czech_utils import normalize, parse_month_references
|
||||||
|
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
This works because `scripts/` is added to `sys.path` at runtime (by `app.py` on startup, by Makefile via `PYTHONPATH`, or by scripts adding their own directory to `sys.path`).
|
||||||
|
|
||||||
|
### Template Style
|
||||||
|
|
||||||
|
- All CSS is inline (no external stylesheets)
|
||||||
|
- No CSS preprocessors or frameworks
|
||||||
|
- No JavaScript frameworks — plain DOM manipulation
|
||||||
|
- Terminal-inspired aesthetic: monospace fonts, green-on-black, dashed borders
|
||||||
|
|
||||||
|
### Commit Conventions
|
||||||
|
|
||||||
|
The project uses [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
```
|
||||||
|
feat: add keyboard navigation to member popup
|
||||||
|
fix: correct diacritic-insensitive search filter
|
||||||
|
chore: update dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
AI commits include a co-author trailer:
|
||||||
|
```
|
||||||
|
Co-authored-by: Antigravity <antigravity@google.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Why No Database?
|
||||||
|
|
||||||
|
Google Sheets serves as the database because:
|
||||||
|
1. Club members can view and correct data without special tools
|
||||||
|
2. No database server to manage or back up
|
||||||
|
3. Built-in version history and collaborative editing
|
||||||
|
4. Good enough for ~40 members and ~hundreds of transactions
|
||||||
|
|
||||||
|
### Why No Template Inheritance?
|
||||||
|
|
||||||
|
Each HTML template is self-contained. While this means CSS duplication, it keeps each page fully independent and easy to understand. For a 3-page app, the duplication cost is minimal.
|
||||||
|
|
||||||
|
### Why Flask Development Server in Production?
|
||||||
|
|
||||||
|
The Docker container runs Flask's built-in server (`python3 app.py`) rather than gunicorn or waitress. This is intentional — the dashboard is an internal tool accessed by one person at a time. The simplicity outweighs the performance cost.
|
||||||
|
|
||||||
|
### Why Scrape HTML When There's an API?
|
||||||
|
|
||||||
|
The Fio transparent page scraping exists as a **zero-configuration fallback**. Not everyone has an API token, and the transparent page is always publicly accessible. The API is preferred when available (richer data, stable IDs).
|
||||||
|
|
||||||
|
## Common Development Tasks
|
||||||
|
|
||||||
|
### Adding a New Web Route
|
||||||
|
|
||||||
|
1. Add the route function in `app.py`:
|
||||||
|
```python
|
||||||
|
@app.route("/new-page")
|
||||||
|
def new_page():
|
||||||
|
# Fetch data
|
||||||
|
record_step("fetch_data")
|
||||||
|
# Process
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("new_page.html", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `templates/new_page.html` (copy structure from `fees.html`)
|
||||||
|
|
||||||
|
3. Add a link in the nav bar across all templates:
|
||||||
|
```html
|
||||||
|
<a href="/new-page">[New Page]</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add a test in `tests/test_app.py`
|
||||||
|
|
||||||
|
### Adding a New Script
|
||||||
|
|
||||||
|
1. Create `scripts/new_script.py`
|
||||||
|
2. Add a Makefile target:
|
||||||
|
```makefile
|
||||||
|
new-target: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/new_script.py
|
||||||
|
```
|
||||||
|
3. Update `make help` output
|
||||||
|
4. Add the `.PHONY` declaration
|
||||||
|
|
||||||
|
### Modifying Fee Rules
|
||||||
|
|
||||||
|
Fee rules are defined as constants in `scripts/attendance.py`:
|
||||||
|
```python
|
||||||
|
FEE_FULL = 750 # 2+ practices
|
||||||
|
FEE_SINGLE = 200 # 1 practice
|
||||||
|
```
|
||||||
|
|
||||||
|
The calculation logic is in `calculate_fee()`:
|
||||||
|
```python
|
||||||
|
def calculate_fee(attendance_count: int) -> int:
|
||||||
|
if attendance_count == 0: return 0
|
||||||
|
if attendance_count == 1: return FEE_SINGLE
|
||||||
|
return FEE_FULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Czech Month Form
|
||||||
|
|
||||||
|
If you encounter a Czech month declension not yet supported, add it to `CZECH_MONTHS` in `scripts/czech_utils.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CZECH_MONTHS = {
|
||||||
|
"leden": 1, "ledna": 1, "lednu": 1,
|
||||||
|
"lednem": 1, # New instrumental case
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project History
|
||||||
|
|
||||||
|
The project evolved through distinct phases:
|
||||||
|
|
||||||
|
1. **Design phase** — Initial brainstorming captured in `docs/project-notes.md`
|
||||||
|
2. **CLI tools** — `calculate_fees.py` and `match_payments.py` for command-line workflows
|
||||||
|
3. **Bank integration** — `fio_utils.py` for transparent page scraping, later API support
|
||||||
|
4. **Google Sheets sync** — `sync_fio_to_sheets.py` + `infer_payments.py` for the ledger pipeline
|
||||||
|
5. **Web dashboard** — `app.py` with the `/fees`, `/reconcile`, and `/payments` pages
|
||||||
|
6. **Interactive features** — Modal popups, QR payments, keyboard navigation, search filter
|
||||||
|
7. **Fee exceptions** — Manual override system via the `exceptions` sheet tab
|
||||||
|
8. **CI/CD** — Gitea Actions for Docker builds and Kubernetes deployment
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No data." on the web dashboard
|
||||||
|
|
||||||
|
The attendance Google Sheet couldn't be fetched, or it returned empty data. Check:
|
||||||
|
- Internet connectivity
|
||||||
|
- The sheet ID in `attendance.py` is still valid
|
||||||
|
- The sheet's public sharing settings haven't changed
|
||||||
|
|
||||||
|
### Slow page loads
|
||||||
|
|
||||||
|
Each page fetches data from Google Sheets on every request (no caching). Typical load times are 1-3 seconds. If significantly slower:
|
||||||
|
- Check the performance breakdown (click the render time in the footer)
|
||||||
|
- Google Sheets API rate limiting may be the cause
|
||||||
|
|
||||||
|
### Import errors in scripts
|
||||||
|
|
||||||
|
Ensure `PYTHONPATH` includes the `scripts/` directory:
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH=scripts:$PYTHONPATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the Makefile, which sets this automatically.
|
||||||
|
|
||||||
|
### "Could not fetch exceptions" warning
|
||||||
|
|
||||||
|
The `exceptions` tab doesn't exist in the Payments Google Sheet. This is non-fatal — reconciliation proceeds without fee overrides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Development guide generated from comprehensive code analysis on 2026-03-03.*
|
||||||
325
docs/by-claude-opus/scripts.md
Normal file
325
docs/by-claude-opus/scripts.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# Scripts Reference
|
||||||
|
|
||||||
|
All scripts live in the `scripts/` directory and are invoked via `make` targets or directly with Python.
|
||||||
|
|
||||||
|
## Pipeline Scripts
|
||||||
|
|
||||||
|
These scripts form the core data processing pipeline. They are typically run in sequence:
|
||||||
|
|
||||||
|
### `sync_fio_to_sheets.py` — Bank → Google Sheet
|
||||||
|
|
||||||
|
Syncs incoming Fio bank transactions to the Payments Google Sheet. Implements an append-only, deduplicated sync — re-running is always safe.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make sync # Last 30 days
|
||||||
|
make sync-2026 # Full year 2026 (Jan 1 – Dec 31, sorted)
|
||||||
|
|
||||||
|
# Direct invocation with options:
|
||||||
|
python scripts/sync_fio_to_sheets.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--from 2026-01-01 --to 2026-03-01 \
|
||||||
|
--sort-by-date
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--days` | `30` | Days to look back (ignored if `--from`/`--to` set) |
|
||||||
|
| `--sheet-id` | Built-in ID | Target Google Sheet |
|
||||||
|
| `--credentials` | `credentials.json` | Path to Google API credentials |
|
||||||
|
| `--from` | *(auto)* | Start date (YYYY-MM-DD) |
|
||||||
|
| `--to` | *(auto)* | End date (YYYY-MM-DD) |
|
||||||
|
| `--sort-by-date` | `false` | Sort the entire sheet by date after sync |
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
|
||||||
|
1. Reads existing Sync IDs (column K) from the Google Sheet
|
||||||
|
2. Fetches transactions from Fio bank (API or transparent page scraping)
|
||||||
|
3. For each transaction, generates a SHA-256 hash: `sha256(date|amount|currency|sender|vs|message|bank_id)`
|
||||||
|
4. Appends only transactions whose hash doesn't exist in the sheet
|
||||||
|
5. Optionally sorts the sheet by date
|
||||||
|
|
||||||
|
**Key functions**:
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `get_sheets_service` | `(credentials_path: str) → Resource` | Authenticates with Google Sheets API. Supports both service accounts and OAuth2 flows. |
|
||||||
|
| `generate_sync_id` | `(tx: dict) → str` | Creates the SHA-256 deduplication hash for a transaction. |
|
||||||
|
| `sort_sheet_by_date` | `(service, spreadsheet_id)` | Sorts all rows (excluding header) by the Date column. |
|
||||||
|
| `sync_to_sheets` | `(spreadsheet_id, credentials_path, ...)` | Main sync logic — read existing, fetch new, deduplicate, append. |
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Connecting to Google Sheets using .secret/fuj-management-bot-credentials.json...
|
||||||
|
Reading existing sync IDs from sheet...
|
||||||
|
Fetching Fio transactions from 2026-02-01 to 2026-03-03...
|
||||||
|
Found 15 transactions.
|
||||||
|
Appending 3 new transactions to the sheet...
|
||||||
|
Sync completed successfully.
|
||||||
|
Sheet sorted by date.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `infer_payments.py` — Auto-Fill Person/Purpose
|
||||||
|
|
||||||
|
Scans the Payments Google Sheet for rows with empty Person/Purpose columns and uses name matching and Czech month parsing to fill them automatically.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make infer
|
||||||
|
|
||||||
|
# Dry run (preview without writing):
|
||||||
|
python scripts/infer_payments.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--sheet-id` | Built-in ID | Target Google Sheet |
|
||||||
|
| `--credentials` | `credentials.json` | Path to Google API credentials |
|
||||||
|
| `--dry-run` | `false` | Print inferences without writing to the sheet |
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
|
||||||
|
1. Reads all rows from the Payments Google Sheet
|
||||||
|
2. Fetches the member list from the Attendance Sheet
|
||||||
|
3. For each row where Person AND Purpose are empty AND there's no "manual fix":
|
||||||
|
- Combines sender name + message text
|
||||||
|
- Attempts to match against member names (using name variants and diacritics normalization)
|
||||||
|
- Parses Czech month references from the message
|
||||||
|
- Writes inferred Person, Purpose, and Amount back to the sheet
|
||||||
|
4. Low-confidence matches are prefixed with `[?]` for manual review
|
||||||
|
|
||||||
|
**Skipping rules**:
|
||||||
|
- If `manual fix` column has any value → skip
|
||||||
|
- If `Person` column already has a value → skip
|
||||||
|
- If `Purpose` column already has a value → skip
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Connecting to Google Sheets...
|
||||||
|
Reading sheet data...
|
||||||
|
Fetching member list for matching...
|
||||||
|
Inffering details for empty rows...
|
||||||
|
Row 45: Inferred Jan Novák for 2026-02 (750 CZK)
|
||||||
|
Row 46: Inferred [?] František Vrbík for 2026-01, 2026-02 (1500 CZK)
|
||||||
|
Applying 2 updates to the sheet...
|
||||||
|
Update completed successfully.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `match_payments.py` — Reconciliation Engine + CLI Report
|
||||||
|
|
||||||
|
The core reconciliation engine. Matches payment transactions against expected fees and generates a detailed report. Also used as a library by `app.py` and `infer_payments.py`.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make reconcile
|
||||||
|
|
||||||
|
# Direct invocation:
|
||||||
|
python scripts/match_payments.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--sheet-id YOUR_SHEET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--sheet-id` | Built-in ID | Payments Google Sheet |
|
||||||
|
| `--credentials` | `.secret/fuj-management-bot-credentials.json` | Google API credentials |
|
||||||
|
| `--bank` | `false` | Fetch directly from Fio bank instead of the Google Sheet |
|
||||||
|
|
||||||
|
**Key functions**:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `_build_name_variants(name)` | Generates searchable name variants from a member name. E.g., "František Vrbík (Štrúdl)" → `["frantisek vrbik", "strudl", "vrbik", "frantisek"]` |
|
||||||
|
| `match_members(text, member_names)` | Finds members mentioned in text. Returns `(name, confidence)` tuples where confidence is `auto` or `review`. |
|
||||||
|
| `infer_transaction_details(tx, member_names)` | Infers member(s) and month(s) for a single transaction. |
|
||||||
|
| `format_date(val)` | Normalizes dates from Google Sheets (handles serial numbers and strings). |
|
||||||
|
| `fetch_sheet_data(spreadsheet_id, credentials_path)` | Reads all rows from the Payments sheet as a list of dicts. |
|
||||||
|
| `fetch_exceptions(spreadsheet_id, credentials_path)` | Reads fee overrides from the `exceptions` sheet tab. |
|
||||||
|
| `reconcile(members, sorted_months, transactions, exceptions)` | **Core engine**: matches transactions to members/months, calculates balances. |
|
||||||
|
| `print_report(result, sorted_months)` | Prints the CLI reconciliation report. |
|
||||||
|
|
||||||
|
**Name matching strategy**:
|
||||||
|
|
||||||
|
The matching algorithm uses multiple tiers, in order of confidence:
|
||||||
|
|
||||||
|
| Priority | What it checks | Confidence |
|
||||||
|
|----------|---------------|-----------|
|
||||||
|
| 1 | Full name (normalized) found in text | `auto` |
|
||||||
|
| 2 | Both first and last name present (any order) | `auto` |
|
||||||
|
| 3 | Nickname from parentheses matches | `auto` |
|
||||||
|
| 4 | Last name only (≥4 chars, not in common surname list) | `review` |
|
||||||
|
| 5 | First name only (≥3 chars) | `review` |
|
||||||
|
|
||||||
|
**Common surnames excluded from last-name-only matching**: `novak`, `novakova`, `prach`
|
||||||
|
|
||||||
|
If any `auto`-confidence match exists, all `review` matches are discarded.
|
||||||
|
|
||||||
|
**Payment allocation**:
|
||||||
|
|
||||||
|
When a transaction matches multiple members and/or multiple months, the amount is split **evenly** across all allocations:
|
||||||
|
```
|
||||||
|
per_allocation = amount / (num_members × num_months)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI report sections**:
|
||||||
|
|
||||||
|
1. **Summary table** — Per-member, per-month grid: `OK`, `UNPAID {amount}`, `{paid}/{expected}`, balance
|
||||||
|
2. **Credits** — Members with positive total balance
|
||||||
|
3. **Debts** — Members with negative total balance
|
||||||
|
4. **Unmatched transactions** — Payments that couldn't be assigned
|
||||||
|
5. **Matched transaction details** — Full breakdown with `[REVIEW]` flags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `calculate_fees.py` — Fee Calculation
|
||||||
|
|
||||||
|
Calculates and prints monthly fees in a simple table format.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make fees
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Member | Jan 2026 | Feb 2026
|
||||||
|
-------------------------------------------------------
|
||||||
|
Jan Novák | 750 CZK (4) | 200 CZK (1)
|
||||||
|
Alice Testová | - | 750 CZK (3)
|
||||||
|
-------------------------------------------------------
|
||||||
|
TOTAL | 750 CZK | 950 CZK
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a simpler CLI version of the `/fees` web page. It only shows adults (tier A).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Modules
|
||||||
|
|
||||||
|
### `attendance.py` — Attendance Data & Fee Logic
|
||||||
|
|
||||||
|
Shared module that fetches attendance data from the Google Sheet and computes fees.
|
||||||
|
|
||||||
|
**Constants**:
|
||||||
|
|
||||||
|
| Constant | Value | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `SHEET_ID` | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` | Attendance Google Sheet ID |
|
||||||
|
| `FEE_FULL` | `750` | Monthly fee for 2+ practices |
|
||||||
|
| `FEE_SINGLE` | `200` | Monthly fee for exactly 1 practice |
|
||||||
|
| `COL_NAME` | `0` | Column index for member name |
|
||||||
|
| `COL_TIER` | `1` | Column index for member tier |
|
||||||
|
| `FIRST_DATE_COL` | `3` | First column with date headers |
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `fetch_csv` | `() → list[list[str]]` | Downloads the attendance sheet as CSV via its public export URL. No authentication needed. |
|
||||||
|
| `parse_dates` | `(header_row) → list[tuple[int, datetime]]` | Parses `M/D/YYYY` dates from the header row and returns `(column_index, date)` pairs. |
|
||||||
|
| `group_by_month` | `(dates) → dict[str, list[int]]` | Groups column indices by `YYYY-MM` month key. |
|
||||||
|
| `calculate_fee` | `(count: int) → int` | Applies fee rules: 0→0, 1→200, 2+→750 CZK. |
|
||||||
|
| `get_members` | `(rows) → list[tuple[str, str, list[str]]]` | Parses member rows. Stops at `# last line` sentinel. Skips comment rows (starting with `#`). |
|
||||||
|
| `get_members_with_fees` | `() → tuple[list, list[str]]` | Full pipeline: fetch → parse → compute. Returns `(members, sorted_months)` where each member is `(name, tier, {month: (fee, count)})`. |
|
||||||
|
|
||||||
|
**Member tier codes**:
|
||||||
|
|
||||||
|
| Tier | Meaning | Fees? |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `A` | Adult | Yes (200 or 750 CZK) |
|
||||||
|
| `J` | Junior | No (separate sheet) |
|
||||||
|
| `X` | Exempt | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `fio_utils.py` — Fio Bank Integration
|
||||||
|
|
||||||
|
Handles fetching transactions from Fio bank, supporting both API and HTML scraping modes.
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `fetch_transactions(date_from, date_to)` | Main entry point. Uses API if `FIO_API_TOKEN` is set, falls back to transparent page scraping. |
|
||||||
|
| `fetch_transactions_api(token, date_from, date_to)` | Fetches via Fio REST API (JSON). Returns richer data including sender account and stable bank IDs. |
|
||||||
|
| `fetch_transactions_transparent(date_from, date_to, account_id)` | Scrapes the public Fio transparent account HTML page. |
|
||||||
|
| `parse_czech_amount(s)` | Parses Czech currency strings like `"1 500,00 CZK"` to float. |
|
||||||
|
| `parse_czech_date(s)` | Parses `DD.MM.YYYY` or `DD/MM/YYYY` to `YYYY-MM-DD`. |
|
||||||
|
|
||||||
|
**FioTableParser** — A custom `HTMLParser` subclass that extracts transaction rows from the second `<table class="table">` on the Fio transparent page. Column mapping:
|
||||||
|
|
||||||
|
| Index | Column |
|
||||||
|
|-------|--------|
|
||||||
|
| 0 | Date (Datum) |
|
||||||
|
| 1 | Amount (Částka) |
|
||||||
|
| 2 | Type (Typ) |
|
||||||
|
| 3 | Sender name (Název protiúčtu) |
|
||||||
|
| 4 | Message (Zpráva pro příjemce) |
|
||||||
|
| 5 | KS (constant symbol) |
|
||||||
|
| 6 | VS (variable symbol) |
|
||||||
|
| 7 | SS (specific symbol) |
|
||||||
|
| 8 | Note (Poznámka) |
|
||||||
|
|
||||||
|
**Transaction dict format** (returned by all fetch functions):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"date": "2026-01-15", # YYYY-MM-DD
|
||||||
|
"amount": 750.0, # Float, always positive (outgoing filtered)
|
||||||
|
"sender": "Jan Novák", # Sender name
|
||||||
|
"message": "příspěvek", # Message for recipient
|
||||||
|
"vs": "12345", # Variable symbol
|
||||||
|
"ks": "", # Constant symbol
|
||||||
|
"ss": "", # Specific symbol
|
||||||
|
"bank_id": "abc123", # Bank operation ID (API only)
|
||||||
|
"user_id": "...", # User identification (API only)
|
||||||
|
"sender_account": "...", # Sender account number (API only)
|
||||||
|
"currency": "CZK" # Currency (API only)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `czech_utils.py` — Czech Language Utilities
|
||||||
|
|
||||||
|
Text processing utilities for Czech language content, critical for matching payment messages.
|
||||||
|
|
||||||
|
**`normalize(text: str) → str`**
|
||||||
|
|
||||||
|
Strips diacritics and lowercases text using Unicode NFKD normalization:
|
||||||
|
- `"Štrúdl"` → `"strudl"`
|
||||||
|
- `"František Vrbík"` → `"frantisek vrbik"`
|
||||||
|
- `"LEDEN 2026"` → `"leden 2026"`
|
||||||
|
|
||||||
|
**`parse_month_references(text: str, default_year=2026) → list[str]`**
|
||||||
|
|
||||||
|
Extracts YYYY-MM month references from Czech free text. Handles a remarkable variety of formats:
|
||||||
|
|
||||||
|
| Input | Output | Pattern |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `"leden"` | `["2026-01"]` | Czech month name |
|
||||||
|
| `"ledna"` | `["2026-01"]` | Czech month declension |
|
||||||
|
| `"01/26"` | `["2026-01"]` | Numeric short year |
|
||||||
|
| `"1/2026"` | `["2026-01"]` | Numeric full year |
|
||||||
|
| `"11+12/2025"` | `["2025-11", "2025-12"]` | Multiple slash-separated |
|
||||||
|
| `"12.2025"` | `["2025-12"]` | Dot notation |
|
||||||
|
| `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | Range with year wrap |
|
||||||
|
| `"říjen"` | `["2025-10"]` | Months ≥ October assumed previous year |
|
||||||
|
|
||||||
|
**`CZECH_MONTHS`** — Dictionary mapping all Czech month name forms (nominative, genitive, locative) to month numbers. 35 entries covering all 12 months in multiple declensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Scripts reference generated from comprehensive code analysis on 2026-03-03.*
|
||||||
145
docs/by-claude-opus/testing.md
Normal file
145
docs/by-claude-opus/testing.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses Python's built-in `unittest` framework with `unittest.mock` for mocking external dependencies (Google Sheets API, attendance data). Tests live in the `tests/` directory.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run all tests
|
||||||
|
make test-v # Run with verbose output
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood:
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=scripts:. python3 -m unittest discover tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The `PYTHONPATH` includes both `scripts/` and the project root so that test files can import from both `app.py` and `scripts/*.py`.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### `test_app.py` — Flask Route Tests
|
||||||
|
|
||||||
|
Tests the Flask web application routes using Flask's built-in test client. All external data fetching is mocked.
|
||||||
|
|
||||||
|
| Test | What it verifies |
|
||||||
|
|------|-----------------|
|
||||||
|
| `test_index_page` | `GET /` returns 200 and contains a redirect to `/fees` |
|
||||||
|
| `test_fees_route` | `GET /fees` renders the fees dashboard with correct member names |
|
||||||
|
| `test_reconcile_route` | `GET /reconcile` renders the reconciliation page with payment matching |
|
||||||
|
| `test_payments_route` | `GET /payments` renders the ledger with grouped transactions |
|
||||||
|
|
||||||
|
**Mocking strategy**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_fees_route(self, mock_get_members):
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
response = self.client.get('/fees')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each test patches the data-fetching functions (`get_members_with_fees`, `fetch_sheet_data`) to return controlled test data, avoiding any network calls.
|
||||||
|
|
||||||
|
**Notable**: The reconcile route test also mocks `fetch_sheet_data` and verifies that the reconciliation engine correctly matches a payment against an expected fee (checking for "OK" in the response).
|
||||||
|
|
||||||
|
### `test_reconcile_exceptions.py` — Reconciliation Logic Tests
|
||||||
|
|
||||||
|
Tests the `reconcile()` function directly (unit tests for the core business logic):
|
||||||
|
|
||||||
|
| Test | What it verifies |
|
||||||
|
|------|-----------------|
|
||||||
|
| `test_reconcile_applies_exceptions` | When a fee exception exists (400 CZK instead of 750), the expected amount is overridden and balance is calculated correctly |
|
||||||
|
| `test_reconcile_fallback_to_attendance` | When no exception exists, the attendance-based fee is used |
|
||||||
|
|
||||||
|
**Why these tests matter**: The exception system is critical for correctness — an incorrect override could cause members to be shown incorrect amounts owed. These tests verify that:
|
||||||
|
- Exceptions properly override the attendance-based fee
|
||||||
|
- The absence of an exception correctly falls back to the standard calculation
|
||||||
|
- Balances are computed correctly against overridden amounts
|
||||||
|
|
||||||
|
## Test Data Patterns
|
||||||
|
|
||||||
|
The tests use minimal but representative data:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# A member with attendance-based fee
|
||||||
|
members = [('Alice', 'A', {'2026-01': (750, 4)})]
|
||||||
|
|
||||||
|
# An exception reducing the fee
|
||||||
|
exceptions = {('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}}
|
||||||
|
|
||||||
|
# A matching payment
|
||||||
|
transactions = [{
|
||||||
|
'date': '2026-01-05',
|
||||||
|
'amount': 400,
|
||||||
|
'person': 'Alice',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'inferred_amount': 400,
|
||||||
|
'sender': 'Alice Sender',
|
||||||
|
'message': 'fee'
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Not Tested
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Name matching logic | ❌ Not tested | `match_members()`, `_build_name_variants()` |
|
||||||
|
| Czech month parsing | ❌ Not tested | `parse_month_references()` |
|
||||||
|
| Fio bank data fetching | ❌ Not tested | Both API and HTML scraping |
|
||||||
|
| Sync deduplication | ❌ Not tested | `generate_sync_id()` |
|
||||||
|
| QR code generation | ❌ Not tested | `/qr` route |
|
||||||
|
| Payment inference | ❌ Not tested | `infer_payments.py` logic |
|
||||||
|
| Multi-person payment splitting | ❌ Not tested | Even split across members/months |
|
||||||
|
| Edge cases | ❌ Not tested | Empty sheets, malformed dates, etc. |
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
### Adding a Flask route test
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In tests/test_app.py
|
||||||
|
|
||||||
|
@patch('app.some_function')
|
||||||
|
def test_new_route(self, mock_fn):
|
||||||
|
mock_fn.return_value = expected_data
|
||||||
|
response = self.client.get('/new-route')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'expected content', response.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a reconciliation logic test
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In tests/test_reconcile_exceptions.py (or a new test file)
|
||||||
|
|
||||||
|
def test_multi_month_payment(self):
|
||||||
|
members = [('Bob', 'A', {
|
||||||
|
'2026-01': (750, 3),
|
||||||
|
'2026-02': (750, 4)
|
||||||
|
})]
|
||||||
|
transactions = [{
|
||||||
|
'date': '2026-02-01',
|
||||||
|
'amount': 1500,
|
||||||
|
'person': 'Bob',
|
||||||
|
'purpose': '2026-01, 2026-02',
|
||||||
|
'inferred_amount': 1500,
|
||||||
|
'sender': 'Bob',
|
||||||
|
'message': 'leden+unor'
|
||||||
|
}]
|
||||||
|
result = reconcile(members, ['2026-01', '2026-02'], transactions)
|
||||||
|
bob = result['members']['Bob']
|
||||||
|
self.assertEqual(bob['months']['2026-01']['paid'], 750)
|
||||||
|
self.assertEqual(bob['months']['2026-02']['paid'], 750)
|
||||||
|
self.assertEqual(bob['total_balance'], 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
166
docs/by-claude-opus/user-guide.md
Normal file
166
docs/by-claude-opus/user-guide.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# User Guide — FUJ Web Dashboard
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Start the dashboard with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard is available at **http://localhost:5001** and provides three pages accessible via the green navigation bar at the top.
|
||||||
|
|
||||||
|
## Page 1: Attendance & Fees (`/fees`)
|
||||||
|
|
||||||
|
This page answers the question: **"How much does each member owe this month?"**
|
||||||
|
|
||||||
|
### What You See
|
||||||
|
|
||||||
|
A table with one row per adult member and one column per month. Each cell shows:
|
||||||
|
|
||||||
|
| Cell | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `750 CZK (4)` | Member owes 750 CZK (attended 4 practices that month) |
|
||||||
|
| `200 CZK (1)` | Member owes 200 CZK (attended 1 practice) |
|
||||||
|
| `-` | Member didn't attend — no fee |
|
||||||
|
| `400 (750) CZK (3)` | Fee **overridden** from 750 to 400 CZK (shown in orange) |
|
||||||
|
|
||||||
|
The bottom row shows **monthly totals** — the total amount expected from all adult members.
|
||||||
|
|
||||||
|
### Fee Rules
|
||||||
|
|
||||||
|
| Practices in a month | Monthly fee |
|
||||||
|
|----------------------|-------------|
|
||||||
|
| 0 | 0 CZK (no charge) |
|
||||||
|
| 1 | 200 CZK |
|
||||||
|
| 2 or more | 750 CZK |
|
||||||
|
|
||||||
|
### Source Links
|
||||||
|
|
||||||
|
At the top, you'll find direct links to:
|
||||||
|
- **Attendance Sheet** — the Google Sheet with raw attendance data
|
||||||
|
- **Payments Ledger** — the Google Sheet with bank transactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page 2: Payment Reconciliation (`/reconcile`)
|
||||||
|
|
||||||
|
This page answers: **"Who has paid, who hasn't, and who owes extra?"**
|
||||||
|
|
||||||
|
### Main Table
|
||||||
|
|
||||||
|
Each cell in the matrix shows the payment status for a member × month combination:
|
||||||
|
|
||||||
|
| Cell | Color | Meaning |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `OK` | 🟢 Green | Fully paid |
|
||||||
|
| `UNPAID 750` | 🔴 Red | Haven't paid at all |
|
||||||
|
| `300/750` | 🔴 Red | Partially paid (300 out of 750) |
|
||||||
|
| `-` | Gray | No fee expected |
|
||||||
|
| `PAID 200` | — | Payment received but no fee expected |
|
||||||
|
|
||||||
|
The rightmost column shows each member's **total balance**:
|
||||||
|
- **Positive** (green): Member has overpaid / has credit
|
||||||
|
- **Negative** (red): Member still owes money
|
||||||
|
- **Zero**: Fully settled
|
||||||
|
|
||||||
|
### Search Filter
|
||||||
|
|
||||||
|
Type in the search box at the top to filter members by name. The search is **diacritic-insensitive** — typing "novak" will match "Novák".
|
||||||
|
|
||||||
|
### Member Details
|
||||||
|
|
||||||
|
Click the **`[i]`** icon next to any member's name to open a detailed popup:
|
||||||
|
|
||||||
|
1. **Status Summary** — Month-by-month breakdown with attendance count, expected fee, paid amount, and status. Overridden fees are marked with an amber asterisk.
|
||||||
|
|
||||||
|
2. **Fee Exceptions** — If any months have manual fee overrides, they're listed here with the override amount and reason.
|
||||||
|
|
||||||
|
3. **Payment History** — Every bank transaction matched to this member, showing the date, amount, sender, and payment message.
|
||||||
|
|
||||||
|
**Keyboard shortcuts** (when the popup is open):
|
||||||
|
- `↑` / `↓` — Navigate to the previous/next member
|
||||||
|
- `Escape` — Close the popup
|
||||||
|
|
||||||
|
### QR Code Payments
|
||||||
|
|
||||||
|
When you hover over an unpaid or partially paid cell, a red **"Pay"** button appears. Clicking it opens a QR code that can be scanned with any Czech banking app. The QR code is pre-filled with:
|
||||||
|
|
||||||
|
- The club's bank account number
|
||||||
|
- The exact amount owed
|
||||||
|
- A payment message identifying the member and month
|
||||||
|
|
||||||
|
This makes it trivial to send a payment link to a member who owes money.
|
||||||
|
|
||||||
|
### Summary Sections
|
||||||
|
|
||||||
|
Below the main table, three additional sections may appear:
|
||||||
|
|
||||||
|
| Section | Shows |
|
||||||
|
|---------|-------|
|
||||||
|
| **Credits** | Members with positive balances (advance payments or overpayments) |
|
||||||
|
| **Debts** | Members with negative balances (outstanding fees) |
|
||||||
|
| **Unmatched Transactions** | Bank transactions that couldn't be automatically matched to any member |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page 3: Payments Ledger (`/payments`)
|
||||||
|
|
||||||
|
This page answers: **"What payments has each member made?"**
|
||||||
|
|
||||||
|
### What You See
|
||||||
|
|
||||||
|
Transactions grouped by member name, each showing:
|
||||||
|
- **Date** — When the payment was received
|
||||||
|
- **Amount** — How much was paid (in CZK)
|
||||||
|
- **Purpose** — Which month(s) the payment covers
|
||||||
|
- **Bank Message** — The original message from the bank transfer
|
||||||
|
|
||||||
|
Transactions are sorted newest-first within each member's section.
|
||||||
|
|
||||||
|
### Unmatched Payments
|
||||||
|
|
||||||
|
Transactions that couldn't be assigned to a member appear under **"Unmatched / Unknown"** — these typically need manual review in the Google Sheet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Footer
|
||||||
|
|
||||||
|
Every page shows a **render time** in the bottom-right corner (very small, gray text). This tells you how long the page took to generate.
|
||||||
|
|
||||||
|
Click on it to reveal a detailed breakdown showing how much time was spent on each step (fetching members, fetching payments, reconciliation, template rendering, etc.). This is mostly useful for debugging slow page loads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### "A member asks how much they owe"
|
||||||
|
|
||||||
|
1. Open `/reconcile`
|
||||||
|
2. Search for the member's name
|
||||||
|
3. Their row shows the exact status per month
|
||||||
|
4. Click `[i]` for detailed payment history
|
||||||
|
|
||||||
|
### "A member wants to pay"
|
||||||
|
|
||||||
|
1. Open `/reconcile`
|
||||||
|
2. Find the unpaid cell
|
||||||
|
3. Hover and click the red **Pay** button
|
||||||
|
4. Share the QR code with the member (screenshot or show on screen)
|
||||||
|
|
||||||
|
### "I want to see all payments from one person"
|
||||||
|
|
||||||
|
1. Open `/payments`
|
||||||
|
2. Scroll to the member's section (alphabetically sorted)
|
||||||
|
|
||||||
|
### "A transaction wasn't matched correctly"
|
||||||
|
|
||||||
|
1. Open the **Payments Ledger** Google Sheet (link at the top of any page)
|
||||||
|
2. Find the row
|
||||||
|
3. Manually correct the **Person** and/or **Purpose** columns
|
||||||
|
4. Put any marker in the **manual fix** column to prevent the inference script from overwriting your edit
|
||||||
|
5. Refresh the web dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*User guide generated from comprehensive code analysis on 2026-03-03.*
|
||||||
256
docs/by-claude-opus/web-app.md
Normal file
256
docs/by-claude-opus/web-app.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Web Application Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The FUJ Management web application is a Flask-based dashboard that provides real-time visibility into club finances. It renders server-side HTML with embedded CSS and JavaScript — no build tools, no npm, no framework. The UI follows a distinctive **terminal-inspired aesthetic** with monospace fonts, green-on-black colors, and dashed borders.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### `GET /` — Index (Redirect)
|
||||||
|
|
||||||
|
Redirects to `/fees` via an HTML meta refresh tag. This exists so the root URL always leads somewhere useful.
|
||||||
|
|
||||||
|
### `GET /fees` — Attendance & Fees Dashboard
|
||||||
|
|
||||||
|
**Template**: `templates/fees.html`
|
||||||
|
|
||||||
|
Displays a table of all adult members with their calculated monthly fees based on attendance. Each cell shows the fee amount (in CZK), the number of practices attended, or a dash for months with zero attendance.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
attendance.py::get_members_with_fees() → Filter to tier "A" (adults)
|
||||||
|
match_payments.py::fetch_exceptions() → Check for fee overrides
|
||||||
|
→ Format cells with override indicators
|
||||||
|
→ Render fees.html with totals row
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual features**:
|
||||||
|
- Fee overrides shown in **orange** with the original amount in parentheses
|
||||||
|
- Empty months shown in muted gray
|
||||||
|
- Monthly totals row at the bottom
|
||||||
|
- Performance timing in the footer (click to expand breakdown)
|
||||||
|
|
||||||
|
**Template variables**:
|
||||||
|
|
||||||
|
| Variable | Type | Content |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `months` | `list[str]` | Month labels like "Jan 2026" |
|
||||||
|
| `results` | `list[dict]` | `{name, months: [{cell, overridden}]}` |
|
||||||
|
| `totals` | `list[str]` | Monthly total strings like "3750 CZK" |
|
||||||
|
| `attendance_url` | `str` | Link to the attendance Google Sheet |
|
||||||
|
| `payments_url` | `str` | Link to the payments Google Sheet |
|
||||||
|
|
||||||
|
### `GET /reconcile` — Payment Reconciliation
|
||||||
|
|
||||||
|
**Template**: `templates/reconcile.html` (802 lines — the most complex template)
|
||||||
|
|
||||||
|
The centerpiece of the application. Shows a matrix of members × months with payment status, plus summary sections for credits, debts, and unmatched transactions.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
attendance.py::get_members_with_fees() → All members + fees
|
||||||
|
match_payments.py::fetch_sheet_data() → All payment transactions
|
||||||
|
match_payments.py::fetch_exceptions() → Fee overrides
|
||||||
|
match_payments.py::reconcile() → Match payments ↔ fees
|
||||||
|
→ Render reconcile.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cell statuses**:
|
||||||
|
|
||||||
|
| Status | CSS Class | Display | Meaning |
|
||||||
|
|--------|-----------|---------|---------|
|
||||||
|
| `empty` | `cell-empty` | `-` | No fee expected, no payment |
|
||||||
|
| `ok` | `cell-ok` | `OK` | Paid in full (green) |
|
||||||
|
| `partial` | `cell-unpaid` | `300/750` | Partially paid (red) |
|
||||||
|
| `unpaid` | `cell-unpaid` | `UNPAID 750` | Nothing paid (red) |
|
||||||
|
| `surplus` | — | `PAID 200` | Payment received but no fee expected |
|
||||||
|
|
||||||
|
**Interactive features**:
|
||||||
|
|
||||||
|
1. **Member detail modal** — Click the `[i]` icon next to any member name to see:
|
||||||
|
- Status summary table (month, attendance count, expected, paid, status)
|
||||||
|
- Fee exceptions (if any, shown in amber)
|
||||||
|
- Full payment history with dates, amounts, senders, and messages
|
||||||
|
|
||||||
|
2. **Keyboard navigation** — When a member modal is open:
|
||||||
|
- `↑` / `↓` arrows navigate between members (respecting search filter)
|
||||||
|
- `Escape` closes the modal
|
||||||
|
|
||||||
|
3. **Name search filter** — Type in the search box to filter members. Uses diacritic-insensitive matching (e.g., typing "novak" matches "Novák").
|
||||||
|
|
||||||
|
4. **QR Payment** — Hover over an unpaid/partial cell to reveal a "Pay" button. Clicking it opens a QR code modal with:
|
||||||
|
- A Czech SPD-format QR code (scannable by Czech banking apps)
|
||||||
|
- Pre-filled account number, amount, and payment message
|
||||||
|
- The QR image is generated server-side via `GET /qr`
|
||||||
|
|
||||||
|
**Client-side data**:
|
||||||
|
|
||||||
|
The template receives a full JSON dump of member data (`member_data`) embedded in a `<script>` tag. This powers the modal without additional API calls:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const memberData = {{ member_data | safe }};
|
||||||
|
const sortedMonths = {{ raw_months | tojson }};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Summary sections** (rendered below the main table):
|
||||||
|
|
||||||
|
| Section | Shown when | Content |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Credits | Any member has positive balance | Names with surplus amounts |
|
||||||
|
| Debts | Any member has negative balance | Names with outstanding amounts (red) |
|
||||||
|
| Unmatched Transactions | Any transaction couldn't be matched | Date, amount, sender, message |
|
||||||
|
|
||||||
|
### `GET /payments` — Payments Ledger
|
||||||
|
|
||||||
|
**Template**: `templates/payments.html`
|
||||||
|
|
||||||
|
Displays all bank transactions grouped by member name. Each member section shows their transactions in reverse chronological order.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
match_payments.py::fetch_sheet_data() → All transactions
|
||||||
|
→ Group by Person column
|
||||||
|
→ Strip [?] markers
|
||||||
|
→ Handle comma-separated people
|
||||||
|
→ Sort by date descending
|
||||||
|
→ Render payments.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-person handling**: If a transaction's "Person" field contains comma-separated names (e.g., "Alice, Bob"), the transaction appears under both Alice's and Bob's sections.
|
||||||
|
|
||||||
|
### `GET /qr` — QR Code Generator
|
||||||
|
|
||||||
|
Returns a PNG image containing a Czech SPD (Short Payment Descriptor) QR code.
|
||||||
|
|
||||||
|
**Query parameters**:
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `account` | `BANK_ACCOUNT` env var | IBAN or Czech account number |
|
||||||
|
| `amount` | `0` | Payment amount |
|
||||||
|
| `message` | *(empty)* | Payment message (max 60 chars) |
|
||||||
|
|
||||||
|
**SPD format**: `SPD*1.0*ACC:{account}*AM:{amount}*CC:CZK*MSG:{message}`
|
||||||
|
|
||||||
|
This format is recognized by all Czech banking apps and generates a pre-filled payment order when scanned.
|
||||||
|
|
||||||
|
## UI Design System
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
| Element | Color | Hex |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| Background | Near-black | `#0c0c0c` |
|
||||||
|
| Base text | Medium gray | `#cccccc` |
|
||||||
|
| Headings, accents, "OK" | Terminal green | `#00ff00` |
|
||||||
|
| Unpaid, debts | Alert red | `#ff3333` |
|
||||||
|
| Fee overrides | Amber/orange | `#ffa500` / `#ffaa00` |
|
||||||
|
| Empty/muted | Dark gray | `#444444` |
|
||||||
|
| Borders | Subtle gray | `#333` (dashed), `#555` (solid) |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
All text uses the system monospace font stack:
|
||||||
|
```css
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", "Courier New", monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
Base font size is 11px with 1.2 line-height — intentionally dense for a data-heavy dashboard.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
A persistent nav bar appears at the top of every page:
|
||||||
|
```
|
||||||
|
[Attendance/Fees] [Payment Reconciliation] [Payments Ledger]
|
||||||
|
```
|
||||||
|
The active page's link is highlighted with inverted colors (black text on green background).
|
||||||
|
|
||||||
|
### Shared Footer
|
||||||
|
|
||||||
|
Every page includes a click-to-expand performance timer showing total render time and a per-step breakdown.
|
||||||
|
|
||||||
|
## Flask Application Architecture
|
||||||
|
|
||||||
|
### Request Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Request → @app.before_request (start timer) → Route handler → Template → Response
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
g.start_time record_step("fetch_members")
|
||||||
|
g.steps = [] record_step("fetch_payments")
|
||||||
|
record_step("process_data")
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
@app.context_processor
|
||||||
|
inject_render_time()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
{{ get_render_time() }}
|
||||||
|
in template footer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Loading
|
||||||
|
|
||||||
|
The Flask app adds the `scripts/` directory to `sys.path` at startup, allowing direct imports from scripts:
|
||||||
|
|
||||||
|
```python
|
||||||
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
|
from attendance import get_members_with_fees, SHEET_ID
|
||||||
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | Bank account for QR code generation |
|
||||||
|
| `FIO_API_TOKEN` | *(none)* | Fio API token (used by `fio_utils.py`) |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
The application has minimal error handling:
|
||||||
|
- If Google Sheets returns no data, routes return a simple "No data." text response
|
||||||
|
- No custom error pages for 404/500
|
||||||
|
- Exceptions propagate to Flask's default error handler (debug mode in development, 500 in production)
|
||||||
|
|
||||||
|
## Template Architecture
|
||||||
|
|
||||||
|
All three page templates share a common structure:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ [Page Name]</title>
|
||||||
|
<style>
|
||||||
|
/* ALL CSS is inline — no external stylesheets */
|
||||||
|
/* ~150-400 lines of CSS per template */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="nav"><!-- 3-link navigation --></div>
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<div class="description"><!-- Source links --></div>
|
||||||
|
|
||||||
|
<!-- Page-specific content -->
|
||||||
|
|
||||||
|
<div class="footer"><!-- Render time --></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Page-specific JavaScript (only in reconcile.html) */
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no shared base template (no Jinja2 template inheritance). CSS is duplicated across templates with small variations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Web application documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
36
docs/by-gemini/README.md
Normal file
36
docs/by-gemini/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# FUJ Management System
|
||||||
|
|
||||||
|
Welcome to the **FUJ Management System**, a streamlined solution for managing Ultimate Frisbee club finances, attendance, and member payments. This system automates the tedious parts of club management, keeping your ledger clean and your reconciliation painless.
|
||||||
|
|
||||||
|
## 🚀 Mission
|
||||||
|
|
||||||
|
The project's goal is to minimize manual entry and potential human error in club management by:
|
||||||
|
1. **Automating Bank Synchronization**: Periodically fetching transactions from Fio bank.
|
||||||
|
2. **Smart Inference**: Using heuristics to match bank transactions to members and payment periods.
|
||||||
|
3. **Visual Reconciliation**: Providing a clear, real-time web dashboard for managers to track who has paid and who is in debt.
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
- **Seamless Bank Integration**: Synchronize transactions directly from the Fio bank API into a Google Spreadsheet.
|
||||||
|
- **Intelligent Matching**: Automatic detection of member names and payment periods from transaction messages using diacritic-insensitive Czech text processing.
|
||||||
|
- **Dynamic Dashboard**: A Flask-powered web interface displaying monthly fees, payment status (OK, Partial, Unpaid), and total balances.
|
||||||
|
- **Manual Overrides**: Support for fee exceptions and manual payment matching when automation needs a human touch.
|
||||||
|
- **QR Payment Generation**: Integrated QR code generation to make paying outstanding fees trivial for members.
|
||||||
|
|
||||||
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.12+ (managed with `uv`)
|
||||||
|
- **Web Framework**: Flask with Jinja2 templates
|
||||||
|
- **Data Storage**: Google Sheets (used as a collaborative database)
|
||||||
|
- **APIs**: Fio Bank API, Google Sheets API v4
|
||||||
|
- **Containerization**: Docker / OCI Images
|
||||||
|
- **Automation**: `Makefile` based workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Documentation Guide
|
||||||
|
|
||||||
|
- [Architecture](architecture.md): High-level system design and data flow.
|
||||||
|
- [User Guide](user-guide.md): How to operate the system as a club manager.
|
||||||
|
- [Support Scripts](scripts.md): Detailed reference for CLI tools.
|
||||||
|
- [Deployment](deployment.md): Technical setup and infrastructure instructions.
|
||||||
48
docs/by-gemini/architecture.md
Normal file
48
docs/by-gemini/architecture.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
The FUJ Management system is designed around a **"Sheet-as-a-Database"** architecture. This allows for easy manual editing and transparency while enabling powerful automation.
|
||||||
|
|
||||||
|
## 🔄 High-Level Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Fio[Fio Bank API] -->|Sync Script| GS(Google Spreadsheet)
|
||||||
|
Att[Attendance Sheet] -->|CSV Export| App(Flask Web App)
|
||||||
|
GS -->|API Fetch| App
|
||||||
|
App -->|Display| UI[Manager Dashboard]
|
||||||
|
GS -.->|Manual Edits| GS
|
||||||
|
App -->|Generate| QR[QR Codes for Members]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Data Ingestion (Bank to Sheet)
|
||||||
|
The synchronization pipeline moves raw bank data into a structured format:
|
||||||
|
- `sync_fio_to_sheets.py` fetches transactions and appends them to the "Transactions" sheet.
|
||||||
|
- Each transaction is assigned a unique `Sync ID` to prevent duplicates.
|
||||||
|
- `infer_payments.py` processes new rows, attempting to fill the `Person`, `Purpose`, and `Inferred Amount` columns based on the message and sender.
|
||||||
|
|
||||||
|
### 2. Logic & Reconciliation
|
||||||
|
The core logic resides in shared Python scripts:
|
||||||
|
- **Attendance**: `attendance.py` pulls the latest practice data from a separate attendance sheet and calculates expected fees (e.g., 0/200/750 CZK rules).
|
||||||
|
- **Matching**: `match_payments.py` performs the "heavy lifting" by correlating members, months, and payments. It handles partial payments, overpayments (credits), and manual exceptions.
|
||||||
|
|
||||||
|
### 3. Presentation Layer
|
||||||
|
The Flask application (`app.py`) serves as the primary interface:
|
||||||
|
- **Fees View**: Shows attendance-based charges.
|
||||||
|
- **Reconciliation View**: The main "truth" dashboard showing balance per member.
|
||||||
|
- **Payments View**: Historical list of transactions grouped by member.
|
||||||
|
|
||||||
|
## 🛡 Security & Authentication
|
||||||
|
|
||||||
|
- **Fio Bank**: Authorized via a private API token (kept in `.secret/`).
|
||||||
|
- **Google Sheets**: Authenticated via a **Service Account** or **OAuth2** (using `.secret/fuj-management-bot-credentials.json`).
|
||||||
|
- **Environment**: Secrets are never committed; the `.secret/` directory is git-ignored.
|
||||||
|
|
||||||
|
## 🧩 Key Components
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Google Spreadsheet** | Unified source of truth for transactions and manual overrides. |
|
||||||
|
| **scripts/** | A suite of CLI utilities for batch processing and data maintenance. |
|
||||||
|
| **Flask App** | Read-only views for state visualization and QR code generation. |
|
||||||
|
| **czech_utils.py** | Diacritic-normalization and NLP for Czech month/name parsing. |
|
||||||
|
```
|
||||||
72
docs/by-gemini/deployment.md
Normal file
72
docs/by-gemini/deployment.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Deployment & Technical Setup
|
||||||
|
|
||||||
|
This document provides instructions for developers and devops engineers to set up and deploy the FUJ Management system.
|
||||||
|
|
||||||
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.12+**: The project uses modern type hinting and syntax features.
|
||||||
|
- **uv**: High-performance Python package installer and resolver.
|
||||||
|
- Install via brew: `brew install uv`
|
||||||
|
- **Docker** (Optional): For containerized deployments.
|
||||||
|
|
||||||
|
## ⚙️ Initial Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**:
|
||||||
|
Using `uv`, everything is handled automatically:
|
||||||
|
```bash
|
||||||
|
make venv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Secrets Management**:
|
||||||
|
Create a `.secret/` directory. You will need two main credentials:
|
||||||
|
- `fuj-management-bot-credentials.json`: A Google Cloud Service Account key with access to the Sheets API.
|
||||||
|
- `fio-token.txt`: (Implicitly used by `fio_utils.py`) Your Fio bank API token.
|
||||||
|
|
||||||
|
Ensure these are never committed! They are ignored by `.gitignore`.
|
||||||
|
|
||||||
|
## 🐳 Containerization
|
||||||
|
|
||||||
|
The project can be built and run as an OCI image.
|
||||||
|
|
||||||
|
1. **Build the image**:
|
||||||
|
```bash
|
||||||
|
make image
|
||||||
|
```
|
||||||
|
This uses the `build/Dockerfile`, which is optimized for small size and security.
|
||||||
|
|
||||||
|
2. **Run the container**:
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
The app exposes port `5001`.
|
||||||
|
|
||||||
|
## 🧪 Testing & Validation
|
||||||
|
|
||||||
|
The project includes a suite of infrastructure and logic tests.
|
||||||
|
|
||||||
|
- **Run all tests**:
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
- **Verbose output**:
|
||||||
|
```bash
|
||||||
|
make test-v
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are located in the `tests/` directory and use the standard Python `unittest` framework. They cover:
|
||||||
|
- CSV parsing logic.
|
||||||
|
- Fee calculation rules.
|
||||||
|
- Name matching and normalization.
|
||||||
|
|
||||||
|
## 🚀 Future Roadmap
|
||||||
|
|
||||||
|
- **Automated Backups**: Regular snapshots of the Google Sheet.
|
||||||
|
- **Authentication Layer**: Login for the web dashboard (currently assumes internal VPN or trusted environment).
|
||||||
|
- **Gitea Actions**: Continuous Integration for building and testing images.
|
||||||
|
```
|
||||||
66
docs/by-gemini/scripts.md
Normal file
66
docs/by-gemini/scripts.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Support Scripts Reference
|
||||||
|
|
||||||
|
The project includes several CLI utilities located in the `scripts/` directory. Most are accessible via `make` targets.
|
||||||
|
|
||||||
|
## 🚀 Primary Scripts
|
||||||
|
|
||||||
|
### `sync_fio_to_sheets.py`
|
||||||
|
**Target**: `make sync` | `make sync-2026`
|
||||||
|
- **Purpose**: Downloads transactions from Fio bank via API and appends new ones to the Google Sheet.
|
||||||
|
- **Key Logic**: Uses a `Sync ID` (SHA-256 hash of transaction details) to ensure that even if the sync is run multiple times, no duplicate rows are created.
|
||||||
|
- **Arguments**:
|
||||||
|
- `--days`: How many days back to look (default 30).
|
||||||
|
- `--from/--to`: Specific date range.
|
||||||
|
- `--sort-by-date`: Re-sorts the spreadsheet after appending.
|
||||||
|
|
||||||
|
### `infer_payments.py`
|
||||||
|
**Target**: `make infer`
|
||||||
|
- **Purpose**: Processes the "Transactions" sheet to fill in `Person`, `Purpose`, and `Inferred Amount`.
|
||||||
|
- **Logic**:
|
||||||
|
- Analyzes the `Sender` and `Message` fields.
|
||||||
|
- Uses `match_payments.py` heuristics to find members.
|
||||||
|
- If confidence is low, prefixes the name with `[?]` to flag it for manual review.
|
||||||
|
- Won't overwrite cells that already have data (respecting your manual fixes).
|
||||||
|
|
||||||
|
### `match_payments.py`
|
||||||
|
**Target**: `make match` | `make reconcile`
|
||||||
|
- **Purpose**: The core "Reconciliation Engine".
|
||||||
|
- **Logic**:
|
||||||
|
- Fetches attendance fees (from `attendance.py`).
|
||||||
|
- Fetches transaction data.
|
||||||
|
- Correlates them based on inferred `Person` and `Purpose`.
|
||||||
|
- Handles "rollover" balances—extra money from one month is tracked as a credit for the next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Utility Modules
|
||||||
|
|
||||||
|
### `attendance.py`
|
||||||
|
- Handles the connection to the Google Attendance sheet (exported as CSV).
|
||||||
|
- Implements the club's fee rules:
|
||||||
|
- 0 practices = 0 CZK
|
||||||
|
- 1 practice = 200 CZK
|
||||||
|
- 2+ practices = 750 CZK
|
||||||
|
- *Note*: Fee calculation only applies to members in Tier "A" (Adults).
|
||||||
|
|
||||||
|
### `czech_utils.py`
|
||||||
|
- **Normalization**: Strips diacritics and lowercases text (e.g., `František` -> `frantisek`).
|
||||||
|
- **Month Parsing**: Advanced regex to detect month references in Czech (e.g., "leden-brezen", "11+12/25", "na únor").
|
||||||
|
|
||||||
|
### `fio_utils.py`
|
||||||
|
- Low-level wrapper for the Fio Bank API.
|
||||||
|
- Handles HTTP requests and JSON parsing for transaction lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Makefile Summary
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `make fees` | Preview calculated fees based on attendance. |
|
||||||
|
| `make sync` | Sync last 30 days of bank data. |
|
||||||
|
| `make infer` | Run smart tagging on the sheet. |
|
||||||
|
| `make reconcile` | Run a text-based reconciliation report in terminal. |
|
||||||
|
| `make web` | Start the Flask dashboard. |
|
||||||
|
| `make test` | Run the test suite. |
|
||||||
|
```
|
||||||
61
docs/by-gemini/user-guide.md
Normal file
61
docs/by-gemini/user-guide.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# User Guide
|
||||||
|
|
||||||
|
This guide is intended for club managers who use the FUJ Management system for day-to-day operations.
|
||||||
|
|
||||||
|
## 🛠 Operational Workflow
|
||||||
|
|
||||||
|
To keep the club finances up-to-date, follow these steps periodically (e.g., once a week):
|
||||||
|
|
||||||
|
1. **Sync Bank Transactions**:
|
||||||
|
Run the sync script to pull the latest payments from Fio.
|
||||||
|
```bash
|
||||||
|
make sync
|
||||||
|
```
|
||||||
|
2. **Infer Payments**:
|
||||||
|
Let the system automatically tag who paid for what.
|
||||||
|
```bash
|
||||||
|
make infer
|
||||||
|
```
|
||||||
|
3. **Manual Review (Google Sheets)**:
|
||||||
|
Open the Google Spreadsheet. Check rows with the `[?]` prefix in the `Person` column—these require human confirmation.
|
||||||
|
- If correct: Remove the `[?]` prefix.
|
||||||
|
- If incorrect: Manually fix the `Person` and `Purpose`.
|
||||||
|
- If a payment covers a special case: Use the **exceptions** sheet to override expected fees.
|
||||||
|
4. **Check Reconciliation Dashboard**:
|
||||||
|
Start the web app to see the final balance report.
|
||||||
|
```bash
|
||||||
|
make web
|
||||||
|
```
|
||||||
|
Navigate to `http://localhost:5001/reconcile`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Understanding the Dashboard
|
||||||
|
|
||||||
|
### Reconciliation Page
|
||||||
|
- **Green (OK)**: Member has paid exactly what was expected (or more).
|
||||||
|
- **Orange (Partial)**: Some payment was received, but there's still a debt.
|
||||||
|
- **Red (UNPAID)**: No payment recorded for this month.
|
||||||
|
- **Blue (SURPLUS)**: Payment received for a month where no fee was expected.
|
||||||
|
|
||||||
|
### Handling Debts
|
||||||
|
If a member is in debt, you can click on the unpaid/partial cell to get a **QR Platba** link. You can send this link or screenshot to the member to facilitate quick payment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ & Troubleshooting
|
||||||
|
|
||||||
|
### Why is a payment "Unmatched"?
|
||||||
|
A payment stays unmatched if neither the sender name nor the message contains recognizable member names or nicknames.
|
||||||
|
- **Fix**: Manually enter the member's name in the `Person` column in the Google Sheet.
|
||||||
|
|
||||||
|
### How do I handle a "Family Discount" or "Prepaid Year"?
|
||||||
|
Use the `exceptions` sheet in the Google Spreadsheet.
|
||||||
|
1. Add the member's name (exactly as it appears in attendance).
|
||||||
|
2. Enter the month (e.g., `2026-03`).
|
||||||
|
3. Enter the new `Amount` (use `0` for prepaid).
|
||||||
|
4. Add a `Note` for clarity.
|
||||||
|
|
||||||
|
### The web app is slow to load.
|
||||||
|
The app fetches data from Google Sheets API on every request. This ensures real-time data but can take a few seconds. The "Performance Breakdown" footer shows exactly where the time was spent.
|
||||||
|
```
|
||||||
43
docs/index.html
Normal file
43
docs/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FUJ Management - Documentation</title>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<meta name="description" content="Documentation for FUJ Management Application">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-color: #42b983;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">Loading documentation...</div>
|
||||||
|
<script>
|
||||||
|
window.$docsify = {
|
||||||
|
name: 'FUJ Management',
|
||||||
|
repo: '',
|
||||||
|
loadSidebar: true,
|
||||||
|
subMaxLevel: 2,
|
||||||
|
search: 'auto',
|
||||||
|
auto2top: true,
|
||||||
|
alias: {
|
||||||
|
'/.*/_sidebar.md': '/_sidebar.md'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<!-- Docsify v4 -->
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/search.min.js"></script>
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify-copy-code"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
1
prompts/2026-03-09-add-pay-all.md
Normal file
1
prompts/2026-03-09-add-pay-all.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Now on both reconiciliation pages in the balance column i want to have a button "Pay All" which will create a new row in the transactions table with amount equal to the balance and with a note same as for payment for single period but stating all periods debt consist of
|
||||||
21
prompts/2026-03-09-junior-fees.md
Normal file
21
prompts/2026-03-09-junior-fees.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
i have new attendance sheet specifically for juniors over here: https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/edit?gid=1213318614#gid=1213318614
|
||||||
|
|
||||||
|
I would like you to treat as junior anyone in that sheet
|
||||||
|
- who does not have tier: X
|
||||||
|
- is above line that says in column A: # Treneri
|
||||||
|
|
||||||
|
i want to create similar page as we have in /fees, but for juniors - let's give it path /fees-juniors
|
||||||
|
|
||||||
|
i want you to merge monthly attendance from both sheets in the document, but from the first sheet collect only attendance for members in tier: J
|
||||||
|
|
||||||
|
Rules for monthly payments will be:
|
||||||
|
- attended only once - put ? mark as output
|
||||||
|
- 2 and more: 500 czk per month
|
||||||
|
|
||||||
|
Also i want to have an option to merge multiple subsequent months to act as one for the payment, for now give me an option to specify it in some datastructure in the code, later we might read it from google sheet. Immediatelly prepare merge of january and february 2026
|
||||||
|
|
||||||
|
Also even though now the monthly payment is one value, i would like to have it configurable per month, for now prepare exception for september 2025 with 250
|
||||||
|
|
||||||
|
---
|
||||||
|
cool, now i need an improvement: if the member name in both sheets is exactly the same i want to treat it as one person. Also i want you to keep attendances from "adult practice" (first sheet) and juniors practices (other one) in a datastructure separately, so that you can display primarily sum of those, but also each of them (in brackets after sum) so that we have better visibility
|
||||||
7
prompts/2026-03-10-cache-data-from-google-sheets.md
Normal file
7
prompts/2026-03-10-cache-data-from-google-sheets.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
i would like to implement caching of data that we load from the google documents. For all of them. I do not need persistence across application restarts, so file of whatever format in tmp directory would be good enough. I think it would be good idea to read metadata about documents we access - last modified time? and reload these files only when document is newer than cached data.
|
||||||
|
|
||||||
|
Suggest solution, suggest file format for caching.
|
||||||
|
|
||||||
|
------------
|
||||||
|
|
||||||
|
i do not need caching for scripts, caching is relevant for web app only
|
||||||
34
prompts/outcomes/2026-03-09-junior-fees.md
Normal file
34
prompts/outcomes/2026-03-09-junior-fees.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Junior Fees Implementation Summary
|
||||||
|
|
||||||
|
Based on the recent updates, we have introduced a dedicated system for tracking, displaying, and reconciling junior team attendances and payments.
|
||||||
|
|
||||||
|
## 1. Implemented Features
|
||||||
|
- **Dual-Sheet Architecture:** The system now pulls attendance from two separate Google Sheet tabs—one for adult practices and another for junior practices.
|
||||||
|
- **New Views:**
|
||||||
|
- `/fees-juniors`: A dedicated dashboard showing junior attendances and calculated fees.
|
||||||
|
- `/reconcile-juniors`: A dedicated page matching Fio bank transactions against expected junior fees.
|
||||||
|
- **Granular Attendance Display:** The UI clearly separates and tallies adult (`A`) and junior (`J`) practice counts for each member (e.g., `4 (2A+2J)` or `2 (J)`).
|
||||||
|
|
||||||
|
## 2. Membership Rules
|
||||||
|
- **Identification:** A member is processed as a junior if they appear in the *Junior Sheet*, UNLESS:
|
||||||
|
- They are listed below the separator line `# Treneri` (or `# Trenéři`).
|
||||||
|
- Their tier is explicitly marked as `X`.
|
||||||
|
- **Adult Sheet Fallback:** Members from the Adult Sheet whose tier is marked as `J` are also tracked as juniors.
|
||||||
|
- **Merging Identities:** If a member has the identical name in both the Adult Sheet and the Junior Sheet, their attendance records are merged together into a single profile.
|
||||||
|
|
||||||
|
## 3. Fee Calculation Rules
|
||||||
|
The base fee calculation for juniors relies on the total combined attendance across both adult and junior practices for a given month:
|
||||||
|
- **0 attendances:** 0 CZK
|
||||||
|
- **Exactly 1 attendance:** `?` (Flags the month for manual review/decision)
|
||||||
|
- **2 or more attendances:** 500 CZK (Default base rate)
|
||||||
|
|
||||||
|
## 4. Exceptions & Overrides
|
||||||
|
We have hardcoded specific timeline and pricing exceptions directly into the logic:
|
||||||
|
|
||||||
|
- **Modified Monthly Rates:**
|
||||||
|
- **September 2025** (`2025-09`) is explicitly configured to have a fee of **250 CZK** for 2+ attendances instead of the default 500 CZK.
|
||||||
|
|
||||||
|
- **Merged Billing Months:**
|
||||||
|
To handle holidays and off-seasons, certain subsequent months are merged and billed as a single period. Their attendances are summed up before the fee rule is applied. The current active merges are:
|
||||||
|
- **December 2025** is merged into **January 2026**
|
||||||
|
- **September 2025** is merged into **October 2025**
|
||||||
29
prompts/outcomes/2026-03-10-cache-data-from-google-sheets.md
Normal file
29
prompts/outcomes/2026-03-10-cache-data-from-google-sheets.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Google Sheets Data Caching Implementation
|
||||||
|
|
||||||
|
**Date:** 2026-03-11
|
||||||
|
**Objective:** Optimize Flask application performance by heavily caching expensive Google Sheets data processing, avoiding redundant HTTP roundtrips to Google APIs, and ensuring rate limits are not exhausted during simple web app reloads.
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### 1. File-Based JSON Caching (`cache_utils.py`)
|
||||||
|
- **Mechanism:** Implemented a new generic caching system that saves API responses and heavily calculated datasets as `.json` files directly to the local `/tmp/` directory.
|
||||||
|
- **Drive Metadata Checks:** The cache is validated by asking the Google Drive API (`drive.files().get`) for the remote `modifiedTime` of the target Sheet.
|
||||||
|
- **Cache Hit logic:** If the cached version on disk matches the remote `modifiedTime`, the application skips downloading the full CSV payload and computing tuples—instead serving the instant static cache via `json.load`.
|
||||||
|
|
||||||
|
### 2. Global API Auth Object Reuse
|
||||||
|
- **The Problem:** The `_get_drive_service()` and `get_sheets_service()` implementations were completely rebuilding `googleapiclient.discovery` objects for *every single file check*—re-seeking and exchanging Google Service Account tokens constantly.
|
||||||
|
- **The Fix:** Service objects (`_DRIVE_SERVICE`, `_SHEETS_SERVICE`) are now globally cached in application memory. The server authenticates exactly *once* when it wakes up, dramatically saving milliseconds and network resources across every web request. The underlying `httplib2` and `google-auth` intelligently handle silent token refreshes natively.
|
||||||
|
|
||||||
|
### 3. Graceful Configurable Rate Limiting
|
||||||
|
- **In-Memory Debouncing:** Implemented an internal memory state (`_LAST_CHECKED`) inside `cache_utils` that forcefully prevents checking the Drive API `modifiedTime` for a specific file if we already explicitly checked it within the last 5 minutes. This prevents flooding the Google Drive API while clicking wildly around the app GUI.
|
||||||
|
- **Semantic Mappings:** Created a `CACHE_SHEET_MAP` that maps friendly internal cache keys (e.g. `attendance_regular`) back to their raw 44-character Google Sheet IDs.
|
||||||
|
|
||||||
|
### 4. HTTP / Socket Timeout Safety Fix
|
||||||
|
- **The Bug:** Originally, `socket.setdefaulttimeout(10)` was used to prevent Google Drive metadata checks from locking up the worker pool. However, this brutally mutated the underlying Werkzeug/Flask default sockets globally. If fetching thousands of lines from Google *Sheets* (the payload logic) took longer than 10 seconds, Flask would just kill the request with a random `TimeoutError('timed out')`.
|
||||||
|
- **The Fix:** Removed the global mutation. Instantiated a targeted, isolated `httplib2.Http(timeout=10)` injected *specifically* into only the Google Drive API build. The rest of the app can now download massive files without randomly timing out.
|
||||||
|
|
||||||
|
### 5. Developer Experience (DX) Enhancements
|
||||||
|
- **Logging Line Origins:** Enriched the console logging format strings (`logging.basicConfig`) to output `[%(funcName)s]` and `%(filename)s:%(lineno)d` to easily trace exactly which exact file and function is executing on complex stack traces.
|
||||||
|
- **Improved VS Code Local Debugging:**
|
||||||
|
- Integrated `debugpy` launch profiles in `.vscode/launch.json` for "Python Debugger: Flask" (Launching) and "Python Debugger: Attach" (Connecting).
|
||||||
|
- Implemented a standard `make web-attach` target inside the Makefile via `uv run python -m debugpy --listen ...` to allow the background web app to automatically halt and wait for external debuggers before bootstrapping caching layers.
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.03"
|
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",
|
||||||
|
"gunicorn>=23.0",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = false
|
package = false
|
||||||
|
|||||||
@@ -5,20 +5,39 @@ import io
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
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"
|
|
||||||
|
|
||||||
FEE_FULL = 750 # CZK, for 2+ practices in a month
|
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
||||||
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
||||||
|
|
||||||
|
ADULT_FEE_DEFAULT = 750 # CZK, for 2+ practices 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_MONTHLY_RATE = {
|
||||||
|
"2025-09": 250
|
||||||
|
}
|
||||||
|
ADULT_MERGED_MONTHS = {
|
||||||
|
#"2025-12": "2026-01", # keys are merged into values
|
||||||
|
#"2025-09": "2025-10"
|
||||||
|
}
|
||||||
|
|
||||||
|
JUNIOR_MERGED_MONTHS = {
|
||||||
|
"2025-12": "2026-01", # keys are merged into values
|
||||||
|
"2025-09": "2025-10"
|
||||||
|
}
|
||||||
|
|
||||||
COL_NAME = 0
|
COL_NAME = 0
|
||||||
COL_TIER = 1
|
COL_TIER = 1
|
||||||
FIRST_DATE_COL = 3
|
FIRST_DATE_COL = 3
|
||||||
|
|
||||||
|
|
||||||
def fetch_csv() -> list[list[str]]:
|
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
|
||||||
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
||||||
req = urllib.request.Request(EXPORT_URL)
|
req = urllib.request.Request(url)
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
text = resp.read().decode("utf-8")
|
text = resp.read().decode("utf-8")
|
||||||
reader = csv.reader(io.StringIO(text))
|
reader = csv.reader(io.StringIO(text))
|
||||||
@@ -28,44 +47,114 @@ def fetch_csv() -> list[list[str]]:
|
|||||||
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
||||||
"""Return (column_index, date) pairs for all date columns."""
|
"""Return (column_index, date) pairs for all date columns."""
|
||||||
dates = []
|
dates = []
|
||||||
|
|
||||||
for i in range(FIRST_DATE_COL, len(header_row)):
|
for i in range(FIRST_DATE_COL, len(header_row)):
|
||||||
raw = header_row[i].strip()
|
raw = header_row[i].strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dates.append((i, datetime.strptime(raw, "%m/%d/%Y")))
|
# Try DD.MM.YYYY
|
||||||
|
dt = datetime.strptime(raw, "%d.%m.%Y")
|
||||||
|
dates.append((i, dt))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
try:
|
||||||
|
# Fallback to MM/DD/YYYY
|
||||||
|
dt = datetime.strptime(raw, "%m/%d/%Y")
|
||||||
|
dates.append((i, dt))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
return dates
|
return dates
|
||||||
|
|
||||||
|
|
||||||
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
|
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
|
||||||
"""Group column indices by YYYY-MM."""
|
"""Group column indices by YYYY-MM, handling merged months."""
|
||||||
months: dict[str, list[int]] = {}
|
months: dict[str, list[int]] = {}
|
||||||
for col, dt in dates:
|
for col, dt in dates:
|
||||||
key = dt.strftime("%Y-%m")
|
key = dt.strftime("%Y-%m")
|
||||||
months.setdefault(key, []).append(col)
|
# Apply merged month mapping if configured
|
||||||
|
target_key = merged_months.get(key, key)
|
||||||
|
months.setdefault(target_key, []).append(col)
|
||||||
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:
|
||||||
|
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
|
||||||
|
if attendance_count == 0:
|
||||||
|
return 0
|
||||||
|
if attendance_count == 1:
|
||||||
|
return "?"
|
||||||
|
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||||
"""Parse member rows. Returns list of (name, tier, row)."""
|
"""Parse member rows. Returns list of (name, tier, row).
|
||||||
|
|
||||||
|
Stopped at row where first column contains '# last line'.
|
||||||
|
Skips rows starting with '#'.
|
||||||
|
"""
|
||||||
members = []
|
members = []
|
||||||
for row in rows[1:]:
|
for row in rows[1:]:
|
||||||
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
|
if not row or len(row) <= COL_NAME:
|
||||||
if not name or name.lower() in ("jméno", "name", "jmeno"):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
first_col = row[COL_NAME].strip()
|
||||||
|
|
||||||
|
# Terminator for rows to process
|
||||||
|
if "# last line" in first_col.lower():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Ignore comments
|
||||||
|
if first_col.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
|
||||||
|
continue
|
||||||
|
|
||||||
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||||
members.append((name, tier, row))
|
members.append((first_col, tier, row))
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def get_junior_members_from_sheet(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||||
|
"""Parse junior member rows from the junior sheet.
|
||||||
|
|
||||||
|
Stopped at row where first column contains '# Treneri'.
|
||||||
|
Returns list of (name, tier, row) for members where tier is not 'X'.
|
||||||
|
"""
|
||||||
|
members = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
if not row or len(row) <= COL_NAME:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_col = row[COL_NAME].strip()
|
||||||
|
|
||||||
|
# Terminator for rows to process in junior sheet
|
||||||
|
if "# treneri" in first_col.lower() or "# trenéři" in first_col.lower():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Ignore comments
|
||||||
|
if first_col.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||||
|
if tier == "X":
|
||||||
|
continue
|
||||||
|
|
||||||
|
members.append((first_col, tier, row))
|
||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +175,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
|||||||
if not dates:
|
if not dates:
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
months = group_by_month(dates)
|
months = group_by_month(dates, ADULT_MERGED_MONTHS)
|
||||||
sorted_months = sorted(months.keys())
|
sorted_months = sorted(months.keys())
|
||||||
members_raw = get_members(rows)
|
members_raw = get_members(rows)
|
||||||
|
|
||||||
@@ -100,8 +189,92 @@ 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))
|
||||||
|
|
||||||
return members, sorted_months
|
return members, sorted_months
|
||||||
|
|
||||||
|
|
||||||
|
def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple[str | int, int, int, int]]]], list[str]]:
|
||||||
|
"""Fetch attendance data from both sheets and compute junior fees.
|
||||||
|
|
||||||
|
Merges members by exact name match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(members, sorted_months) where members is a list of
|
||||||
|
(name, tier, {month_key: (fee, total_count, adult_count, junior_count)}).
|
||||||
|
"""
|
||||||
|
main_rows = fetch_csv(EXPORT_URL)
|
||||||
|
junior_rows = fetch_csv(JUNIOR_EXPORT_URL)
|
||||||
|
|
||||||
|
if len(main_rows) < 2 or len(junior_rows) < 2:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
main_dates = parse_dates(main_rows[0])
|
||||||
|
junior_dates = parse_dates(junior_rows[0])
|
||||||
|
|
||||||
|
main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS)
|
||||||
|
junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS)
|
||||||
|
|
||||||
|
# Collect all unique sorted months
|
||||||
|
all_months = set(main_months.keys()).union(set(junior_months.keys()))
|
||||||
|
sorted_months = sorted(list(all_months))
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
merged_members: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Process Junior Tier from Main Sheet (Adult Practices)
|
||||||
|
main_members_raw = get_members(main_rows)
|
||||||
|
for name, tier, row in main_members_raw:
|
||||||
|
if tier != "J":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name not in merged_members:
|
||||||
|
merged_members[name] = {"tier": tier, "months": {}}
|
||||||
|
|
||||||
|
for month_key in sorted_months:
|
||||||
|
if month_key not in merged_members[name]["months"]:
|
||||||
|
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||||
|
|
||||||
|
cols = main_months.get(month_key, [])
|
||||||
|
adult_count = sum(
|
||||||
|
1
|
||||||
|
for c in cols
|
||||||
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
|
)
|
||||||
|
merged_members[name]["months"][month_key]["adult"] += adult_count
|
||||||
|
|
||||||
|
# Process Junior Sheet (Junior Practices)
|
||||||
|
junior_members_raw = get_junior_members_from_sheet(junior_rows)
|
||||||
|
for name, tier, row in junior_members_raw:
|
||||||
|
if name not in merged_members:
|
||||||
|
merged_members[name] = {"tier": tier, "months": {}}
|
||||||
|
|
||||||
|
for month_key in sorted_months:
|
||||||
|
if month_key not in merged_members[name]["months"]:
|
||||||
|
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||||
|
|
||||||
|
cols = junior_months.get(month_key, [])
|
||||||
|
junior_count = sum(
|
||||||
|
1
|
||||||
|
for c in cols
|
||||||
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
|
)
|
||||||
|
merged_members[name]["months"][month_key]["junior"] += junior_count
|
||||||
|
|
||||||
|
# Compile the final result format
|
||||||
|
members = []
|
||||||
|
for name, data in merged_members.items():
|
||||||
|
month_fees = {}
|
||||||
|
for month_key in sorted_months:
|
||||||
|
adult_count = data["months"].get(month_key, {}).get("adult", 0)
|
||||||
|
junior_count = data["months"].get(month_key, {}).get("junior", 0)
|
||||||
|
total_count = adult_count + junior_count
|
||||||
|
|
||||||
|
fee = calculate_junior_fee(total_count, month_key)
|
||||||
|
month_fees[month_key] = (fee, total_count, adult_count, junior_count)
|
||||||
|
|
||||||
|
members.append((name, data["tier"], month_fees))
|
||||||
|
|
||||||
|
return members, sorted_months
|
||||||
|
|||||||
173
scripts/cache_utils.py
Normal file
173
scripts/cache_utils.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CACHE_DIR, CREDENTIALS_PATH as CREDS_PATH, DRIVE_TIMEOUT,
|
||||||
|
CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, CACHE_SHEET_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global state to track last Drive API check time per sheet
|
||||||
|
_LAST_CHECKED = {}
|
||||||
|
_DRIVE_SERVICE = None
|
||||||
|
|
||||||
|
def _get_drive_service():
|
||||||
|
global _DRIVE_SERVICE
|
||||||
|
if _DRIVE_SERVICE is not None:
|
||||||
|
return _DRIVE_SERVICE
|
||||||
|
|
||||||
|
if not CREDS_PATH.exists():
|
||||||
|
logger.warning(f"Credentials not found at {CREDS_PATH}. Cannot check Google Drive API.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = service_account.Credentials.from_service_account_file(
|
||||||
|
str(CREDS_PATH),
|
||||||
|
scopes=["https://www.googleapis.com/auth/drive.readonly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply timeout safely to the httplib2 connection without mutating global socket
|
||||||
|
import httplib2
|
||||||
|
import google_auth_httplib2
|
||||||
|
http = httplib2.Http(timeout=DRIVE_TIMEOUT)
|
||||||
|
http = google_auth_httplib2.AuthorizedHttp(creds, http=http)
|
||||||
|
|
||||||
|
_DRIVE_SERVICE = build("drive", "v3", http=http, cache_discovery=False)
|
||||||
|
return _DRIVE_SERVICE
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to build Drive API service: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
def get_sheet_modified_time(cache_key: str) -> str | None:
|
||||||
|
"""Gets the modifiedTime from Google Drive API for a given cache_key.
|
||||||
|
Returns the ISO timestamp string if successful.
|
||||||
|
If the Drive API fails (e.g., lack of permissions for public sheets),
|
||||||
|
it generates a virtual time bucket string to provide a 5-minute TTL cache.
|
||||||
|
"""
|
||||||
|
sheet_id = CACHE_SHEET_MAP.get(cache_key, cache_key)
|
||||||
|
|
||||||
|
cache_file = CACHE_DIR / f"{cache_key}_cache.json"
|
||||||
|
|
||||||
|
# 1. Check if we should skip the Drive API check entirely (global memory TTL)
|
||||||
|
now = time.time()
|
||||||
|
last_check = _LAST_CHECKED.get(sheet_id, 0)
|
||||||
|
|
||||||
|
if CACHE_API_CHECK_TTL_SECONDS > 0 and (now - last_check) < CACHE_API_CHECK_TTL_SECONDS:
|
||||||
|
# We checked recently. Return cached modifiedTime if cache file exists.
|
||||||
|
if cache_file.exists():
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time:
|
||||||
|
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_API_CHECK_TTL_SECONDS}s API check TTL")
|
||||||
|
return cached_time
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading existing cache during API skip for {sheet_id}: {e}")
|
||||||
|
|
||||||
|
# 2. Check if the cache file is simply too new (legacy check)
|
||||||
|
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
|
||||||
|
try:
|
||||||
|
file_mtime = cache_file.stat().st_mtime
|
||||||
|
if time.time() - file_mtime < CACHE_TTL_SECONDS:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time:
|
||||||
|
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_TTL_SECONDS}s max CACHE_TTL")
|
||||||
|
# We consider this a valid check, update the global state
|
||||||
|
_LAST_CHECKED[sheet_id] = now
|
||||||
|
return cached_time
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking cache TTL for {sheet_id}: {e}")
|
||||||
|
|
||||||
|
def _fallback_ttl():
|
||||||
|
bucket = int(time.time() // 300)
|
||||||
|
return f"ttl-5m-{bucket}"
|
||||||
|
|
||||||
|
logger.info(f"Checking Drive API for {sheet_id}")
|
||||||
|
drive_service = _get_drive_service()
|
||||||
|
if not drive_service:
|
||||||
|
return _fallback_ttl()
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_meta = drive_service.files().get(fileId=sheet_id, fields="modifiedTime", supportsAllDrives=True).execute()
|
||||||
|
# Successfully checked API, update the global state
|
||||||
|
_LAST_CHECKED[sheet_id] = time.time()
|
||||||
|
return file_meta.get("modifiedTime")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get modifiedTime for sheet {sheet_id}: {e}. Falling back to 5-minute TTL.")
|
||||||
|
return _fallback_ttl()
|
||||||
|
|
||||||
|
def read_cache(sheet_id: str, current_modified_time: str) -> list | dict | None:
|
||||||
|
"""Reads the JSON cache for the given sheet_id.
|
||||||
|
Returns the cached data if it exists AND the cached modifiedTime matches
|
||||||
|
current_modified_time.
|
||||||
|
Otherwise, returns None.
|
||||||
|
"""
|
||||||
|
if not current_modified_time:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time == current_modified_time:
|
||||||
|
logger.info(f"Cache hit for {sheet_id} ({current_modified_time})")
|
||||||
|
return cache_data.get("data")
|
||||||
|
else:
|
||||||
|
logger.info(f"Cache miss for {sheet_id}. Cached: {cached_time}, Current: {current_modified_time}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read cache {cache_file}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
|
||||||
|
"""Writes the data to a JSON cache file with the given modified_time."""
|
||||||
|
if not modified_time:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||||
|
|
||||||
|
cache_data = {
|
||||||
|
"modifiedTime": modified_time,
|
||||||
|
"data": data,
|
||||||
|
"cachedAt": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(cache_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cache_data, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(f"Wrote cache for {sheet_id}")
|
||||||
|
except Exception as 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
|
||||||
39
scripts/config.py
Normal file
39
scripts/config.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Centralized configuration for FUJ management scripts.
|
||||||
|
|
||||||
|
External service IDs, credentials, and tunable parameters.
|
||||||
|
Domain-specific constants (fees, column indices) stay in their respective modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
CREDENTIALS_PATH = Path(os.environ.get(
|
||||||
|
"CREDENTIALS_PATH",
|
||||||
|
str(PROJECT_ROOT / ".secret" / "fuj-management-bot-credentials.json"),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Google Sheets IDs
|
||||||
|
ATTENDANCE_SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||||
|
PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||||
|
|
||||||
|
# Attendance sheet tab GIDs
|
||||||
|
JUNIOR_SHEET_GID = "1213318614"
|
||||||
|
|
||||||
|
# Bank
|
||||||
|
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
CACHE_DIR = PROJECT_ROOT / "tmp"
|
||||||
|
DRIVE_TIMEOUT = 10 # seconds
|
||||||
|
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
||||||
|
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
||||||
|
|
||||||
|
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
||||||
|
CACHE_SHEET_MAP = {
|
||||||
|
"attendance_regular": ATTENDANCE_SHEET_ID,
|
||||||
|
"attendance_juniors": ATTENDANCE_SHEET_ID,
|
||||||
|
"exceptions_dict": PAYMENTS_SHEET_ID,
|
||||||
|
"payments_transactions": PAYMENTS_SHEET_ID,
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = F
|
|||||||
member_names = [m[0] for m in members_data]
|
member_names = [m[0] for m in members_data]
|
||||||
|
|
||||||
# 3. Process rows
|
# 3. Process rows
|
||||||
print("Inffering details for empty rows...")
|
print("Inferring details for empty rows...")
|
||||||
updates = []
|
updates = []
|
||||||
|
|
||||||
for i, row in enumerate(rows[1:], start=2):
|
for i, row in enumerate(rows[1:], start=2):
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from attendance import get_members_with_fees
|
from attendance import get_members_with_fees
|
||||||
from czech_utils import normalize, parse_month_references
|
from czech_utils import normalize, parse_month_references
|
||||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||||
@@ -159,6 +162,27 @@ def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(val) -> str:
|
||||||
|
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Handle Google Sheets serial dates (number of days since 1899-12-30)
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
base_date = datetime(1899, 12, 30)
|
||||||
|
dt = base_date + timedelta(days=val)
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
val_str = str(val).strip()
|
||||||
|
if not val_str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# If already YYYY-MM-DD, return as is
|
||||||
|
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
|
||||||
|
return val_str
|
||||||
|
|
||||||
|
return val_str
|
||||||
|
|
||||||
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
||||||
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
|
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
|
||||||
service = get_sheets_service(credentials_path)
|
service = get_sheets_service(credentials_path)
|
||||||
@@ -191,13 +215,18 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
|
||||||
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Required columns missing from payments sheet: {', '.join(missing)}. Found headers: {header}")
|
||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
for row in rows[1:]:
|
for row in rows[1:]:
|
||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||||
|
|
||||||
tx = {
|
tx = {
|
||||||
"date": get_val(idx_date),
|
"date": format_date(get_val(idx_date)),
|
||||||
"amount": get_val(idx_amount),
|
"amount": get_val(idx_amount),
|
||||||
"manual_fix": get_val(idx_manual),
|
"manual_fix": get_val(idx_manual),
|
||||||
"person": get_val(idx_person),
|
"person": get_val(idx_person),
|
||||||
@@ -212,10 +241,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.
|
||||||
|
|
||||||
@@ -226,15 +294,36 @@ def reconcile(
|
|||||||
"""
|
"""
|
||||||
member_names = [name for name, _, _ in members]
|
member_names = [name for name, _, _ in members]
|
||||||
member_tiers = {name: tier for name, tier, _ in members}
|
member_tiers = {name: tier for name, tier, _ in members}
|
||||||
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members}
|
member_fees = {name: fees for name, _, fees in members}
|
||||||
|
|
||||||
# Initialize ledger
|
# Initialize ledger
|
||||||
ledger: dict[str, dict[str, dict]] = {}
|
ledger: dict[str, dict[str, dict]] = {}
|
||||||
|
other_ledger: dict[str, list] = {}
|
||||||
|
exceptions = exceptions or {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
ledger[name] = {}
|
ledger[name] = {}
|
||||||
|
other_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, list)) else fee_data
|
||||||
|
attendance_count = fee_data[1] if isinstance(fee_data, (tuple, list)) 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": [],
|
||||||
}
|
}
|
||||||
@@ -249,12 +338,13 @@ def reconcile(
|
|||||||
|
|
||||||
# Strip markers like [?]
|
# Strip markers like [?]
|
||||||
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
||||||
|
is_other = purpose_str.lower().startswith("other:")
|
||||||
|
|
||||||
if person_str and purpose_str:
|
if person_str and purpose_str:
|
||||||
# We have pre-matched data (either from script or manual)
|
# We have pre-matched data (either from script or manual)
|
||||||
# Support multiple people/months in the comma-separated string
|
# Support multiple people/months in the comma-separated string
|
||||||
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
|
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
|
||||||
matched_months = [m.strip() for m in purpose_str.split(",") if m.strip()]
|
matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()]
|
||||||
|
|
||||||
# Use Inferred Amount if available, otherwise bank Amount
|
# Use Inferred Amount if available, otherwise bank Amount
|
||||||
amount = tx.get("inferred_amount")
|
amount = tx.get("inferred_amount")
|
||||||
@@ -280,16 +370,32 @@ def reconcile(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Allocate payment across matched members and months
|
# Allocate payment across matched members and months
|
||||||
|
if is_other:
|
||||||
|
num_allocations = len(matched_members)
|
||||||
|
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||||
|
for member_name, confidence in matched_members:
|
||||||
|
if member_name in other_ledger:
|
||||||
|
other_ledger[member_name].append({
|
||||||
|
"amount": per_allocation,
|
||||||
|
"date": tx["date"],
|
||||||
|
"sender": tx["sender"],
|
||||||
|
"message": tx["message"],
|
||||||
|
"purpose": purpose_str,
|
||||||
|
"confidence": confidence,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
num_allocations = len(matched_members) * len(matched_months)
|
num_allocations = len(matched_members) * len(matched_months)
|
||||||
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||||
|
|
||||||
for member_name, confidence in matched_members:
|
for member_name, confidence in matched_members:
|
||||||
# If we matched via sheet 'Person' column, name might be partial or have markers
|
|
||||||
# but usually it's the exact member name from get_members_with_fees.
|
|
||||||
# Let's ensure it exists in our ledger.
|
|
||||||
if member_name not in ledger:
|
if member_name not in ledger:
|
||||||
# Try matching by base name if it was Jan Novak (Kačerr) etc.
|
logger.warning(
|
||||||
pass
|
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
||||||
|
member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||||
|
)
|
||||||
|
unmatched.append(tx)
|
||||||
|
continue
|
||||||
|
|
||||||
for month_key in matched_months:
|
for month_key in matched_months:
|
||||||
entry = {
|
entry = {
|
||||||
@@ -299,7 +405,7 @@ def reconcile(
|
|||||||
"message": tx["message"],
|
"message": tx["message"],
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
}
|
}
|
||||||
if month_key in ledger.get(member_name, {}):
|
if month_key in ledger[member_name]:
|
||||||
ledger[member_name][month_key]["paid"] += per_allocation
|
ledger[member_name][month_key]["paid"] += per_allocation
|
||||||
ledger[member_name][month_key]["transactions"].append(entry)
|
ledger[member_name][month_key]["transactions"].append(entry)
|
||||||
else:
|
else:
|
||||||
@@ -310,7 +416,7 @@ def reconcile(
|
|||||||
final_balances: dict[str, int] = {}
|
final_balances: dict[str, int] = {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
window_balance = sum(
|
window_balance = sum(
|
||||||
int(mdata["paid"]) - mdata["expected"]
|
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
|
||||||
for mdata in ledger[name].values()
|
for mdata in ledger[name].values()
|
||||||
)
|
)
|
||||||
final_balances[name] = window_balance + credits.get(name, 0)
|
final_balances[name] = window_balance + credits.get(name, 0)
|
||||||
@@ -320,6 +426,7 @@ def reconcile(
|
|||||||
name: {
|
name: {
|
||||||
"tier": member_tiers[name],
|
"tier": member_tiers[name],
|
||||||
"months": ledger[name],
|
"months": ledger[name],
|
||||||
|
"other_transactions": other_ledger[name],
|
||||||
"total_balance": final_balances[name]
|
"total_balance": final_balances[name]
|
||||||
}
|
}
|
||||||
for name in member_names
|
for name in member_names
|
||||||
@@ -371,10 +478,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:
|
||||||
@@ -383,6 +492,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"
|
||||||
@@ -488,7 +598,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ from googleapiclient.discovery import build
|
|||||||
|
|
||||||
from fio_utils import fetch_transactions
|
from fio_utils import fetch_transactions
|
||||||
|
|
||||||
# Configuration
|
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
|
||||||
DEFAULT_SPREADSHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
|
||||||
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||||
TOKEN_FILE = "token.pickle"
|
TOKEN_FILE = "token.pickle"
|
||||||
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
||||||
|
_SHEETS_SERVICE = None
|
||||||
|
|
||||||
def get_sheets_service(credentials_path: str):
|
def get_sheets_service(credentials_path: str):
|
||||||
"""Authenticate and return the Google Sheets API service."""
|
"""Authenticate and return the Google Sheets API service."""
|
||||||
|
global _SHEETS_SERVICE
|
||||||
|
if _SHEETS_SERVICE is not None:
|
||||||
|
return _SHEETS_SERVICE
|
||||||
|
|
||||||
if not os.path.exists(credentials_path):
|
if not os.path.exists(credentials_path):
|
||||||
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
||||||
|
|
||||||
@@ -50,7 +55,8 @@ def get_sheets_service(credentials_path: str):
|
|||||||
with open(TOKEN_FILE, "wb") as token:
|
with open(TOKEN_FILE, "wb") as token:
|
||||||
pickle.dump(creds, token)
|
pickle.dump(creds, token)
|
||||||
|
|
||||||
return build("sheets", "v4", credentials=creds)
|
_SHEETS_SERVICE = build("sheets", "v4", credentials=creds)
|
||||||
|
return _SHEETS_SERVICE
|
||||||
|
|
||||||
|
|
||||||
def generate_sync_id(tx: dict) -> str:
|
def generate_sync_id(tx: dict) -> str:
|
||||||
|
|||||||
900
templates/adults.html
Normal file
900
templates/adults.html
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ Adults Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-pos {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-neg {
|
||||||
|
color: #ff3333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-ok {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid {
|
||||||
|
color: #ff3333;
|
||||||
|
background-color: rgba(255, 51, 51, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #ff3333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row:hover .pay-btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-val {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-row {
|
||||||
|
font-family: inherit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 100px 200px 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-header {
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
width: 250px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
color: #888;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #00ff00;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
#memberModal {
|
||||||
|
display: none !important;
|
||||||
|
/* Force hide by default */
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#memberModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #ff3333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th,
|
||||||
|
.modal-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-meta {
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-amount {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-sender {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-msg {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-breakdown {
|
||||||
|
display: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Modal styles */
|
||||||
|
#qrModal .modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image img {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details div {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details span {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults" class="active">[Adults]</a>
|
||||||
|
<a href="/juniors">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Adults Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-container">
|
||||||
|
<span class="filter-label">search member:</span>
|
||||||
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reconcileBody">
|
||||||
|
{% for row in results %}
|
||||||
|
<tr class="member-row">
|
||||||
|
<td class="member-name">
|
||||||
|
{{ row.name }}
|
||||||
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
|
</td>
|
||||||
|
{% for cell in row.months %}
|
||||||
|
<td title="{{ cell.tooltip }}"
|
||||||
|
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
|
||||||
|
{{ cell.text }}
|
||||||
|
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
||||||
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
|
{% if row.balance < 0 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
{% for t in totals %}
|
||||||
|
<td class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{ t.text }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if credits %}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in credits %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if debts %}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in debts %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unmatched %}
|
||||||
|
<h2>Unmatched Transactions</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
<div class="unmatched-row unmatched-header">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Sender</span>
|
||||||
|
<span>Message</span>
|
||||||
|
</div>
|
||||||
|
{% for tx in unmatched %}
|
||||||
|
<div class="unmatched-row">
|
||||||
|
<span>{{ tx.date }}</span>
|
||||||
|
<span>{{ tx.amount }}</span>
|
||||||
|
<span>{{ tx.sender }}</span>
|
||||||
|
<span>{{ tx.message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div id="qrModal" class="modal"
|
||||||
|
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for ...</div>
|
||||||
|
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="memberModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="closeModal()">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modalStatusBody">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set rt = get_render_time() %}
|
||||||
|
<div class="footer"
|
||||||
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const memberData = {{ member_data| safe }};
|
||||||
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
function showMemberDetails(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = memberData[name];
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
|
||||||
|
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Collect all transactions for listing
|
||||||
|
const allTransactions = [];
|
||||||
|
|
||||||
|
// We need to iterate over months in reverse to show newest first
|
||||||
|
const monthKeys = Object.keys(data.months).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
const mdata = data.months[m];
|
||||||
|
const expected = mdata.expected || 0;
|
||||||
|
const paid = mdata.paid || 0;
|
||||||
|
const attendance = mdata.attendance_count || 0;
|
||||||
|
const originalExpected = mdata.original_expected;
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (expected > 0 || paid > 0) {
|
||||||
|
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
|
||||||
|
else if (paid > 0) { status = paid + '/' + expected; }
|
||||||
|
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCell = mdata.exception
|
||||||
|
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
|
||||||
|
: expected;
|
||||||
|
|
||||||
|
const displayMonth = monthLabels[m] || m;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="color: #888;">${displayMonth}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${attendance}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${paid}</td>
|
||||||
|
<td style="text-align: right;" class="${statusClass}">${status}</td>
|
||||||
|
`;
|
||||||
|
statusBody.appendChild(row);
|
||||||
|
|
||||||
|
if (mdata.transactions) {
|
||||||
|
mdata.transactions.forEach(tx => {
|
||||||
|
allTransactions.push({ month: m, ...tx });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exList = document.getElementById('modalExceptionList');
|
||||||
|
const exSection = document.getElementById('modalExceptionSection');
|
||||||
|
exList.innerHTML = '';
|
||||||
|
|
||||||
|
const exceptions = [];
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
if (data.months[m].exception) {
|
||||||
|
exceptions.push({ month: m, ...data.months[m].exception });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exceptions.length > 0) {
|
||||||
|
exSection.style.display = 'block';
|
||||||
|
exceptions.forEach(ex => {
|
||||||
|
const displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item'; // Reuse style
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${displayMonth}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
|
||||||
|
`;
|
||||||
|
exList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.other_transactions && data.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
data.other_transactions.forEach(tx => {
|
||||||
|
const displayPurpose = tx.purpose || 'Other';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const txList = document.getElementById('modalTxList');
|
||||||
|
txList.innerHTML = '';
|
||||||
|
|
||||||
|
if (allTransactions.length === 0) {
|
||||||
|
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
||||||
|
} else {
|
||||||
|
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
|
||||||
|
const displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
txList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('memberModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
if (id) {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
if (id === 'qrModal') {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('memberModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing filter script
|
||||||
|
document.getElementById('nameFilter').addEventListener('input', function (e) {
|
||||||
|
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
const rows = document.querySelectorAll('.member-row');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const nameNode = row.querySelector('.member-name');
|
||||||
|
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
if (name.includes(filterValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Esc and Navigate with Arrows
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
closeModal('qrModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (modal.classList.contains('active')) {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateMember(-1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateMember(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateMember(direction) {
|
||||||
|
const rows = Array.from(document.querySelectorAll('.member-row'));
|
||||||
|
const visibleRows = rows.filter(row => row.style.display !== 'none');
|
||||||
|
|
||||||
|
let currentIndex = visibleRows.findIndex(row => {
|
||||||
|
const nameNode = row.querySelector('.member-name');
|
||||||
|
const name = nameNode.childNodes[0].textContent.trim();
|
||||||
|
return name === currentMemberName;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
let nextIndex = currentIndex + direction;
|
||||||
|
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
|
||||||
|
const nextRow = visibleRows[nextIndex];
|
||||||
|
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
|
||||||
|
showMemberDetails(nextName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showPayQR(name, amount, month, rawMonth) {
|
||||||
|
const account = "{{ bank_account }}";
|
||||||
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
|
const qrImg = document.getElementById('qrImg');
|
||||||
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
|
const qrAmount = document.getElementById('qrAmount');
|
||||||
|
const qrMessage = document.getElementById('qrMessage');
|
||||||
|
|
||||||
|
qrTitle.innerText = `Payment for ${month}`;
|
||||||
|
qrAccount.innerText = account;
|
||||||
|
qrAmount.innerText = amount;
|
||||||
|
qrMessage.innerText = message;
|
||||||
|
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
|
||||||
|
|
||||||
|
qrImg.src = qrUrl;
|
||||||
|
document.getElementById('qrModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target.className === 'modal') {
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
```
|
||||||
253
templates/fees-juniors.html
Normal file
253
templates/fees-juniors.html
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<!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 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>
|
||||||
|
|
||||||
|
<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')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -102,12 +102,25 @@
|
|||||||
/* 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;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -128,6 +141,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -143,14 +173,45 @@
|
|||||||
.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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees" class="active">[Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
<a href="/adults">[Adults]</a>
|
||||||
<a href="/payments">[Payments Ledger]</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 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>FUJ Fees Dashboard</h1>
|
<h1>FUJ Fees Dashboard</h1>
|
||||||
@@ -175,8 +236,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 +255,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')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
167
templates/flush-cache.html
Normal file
167
templates/flush-cache.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!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="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<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>
|
||||||
881
templates/juniors.html
Normal file
881
templates/juniors.html
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ Juniors Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-pos {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-neg {
|
||||||
|
color: #ff3333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-ok {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid {
|
||||||
|
color: #ff3333;
|
||||||
|
background-color: rgba(255, 51, 51, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #ff3333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row:hover .pay-btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-val {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-row {
|
||||||
|
font-family: inherit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 100px 200px 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-header {
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
width: 250px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
color: #888;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #00ff00;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
#memberModal {
|
||||||
|
display: none !important;
|
||||||
|
/* Force hide by default */
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#memberModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #ff3333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th,
|
||||||
|
.modal-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-meta {
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-amount {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-sender {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-msg {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-breakdown {
|
||||||
|
display: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Modal styles */
|
||||||
|
#qrModal .modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image img {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details div {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details span {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults">[Adults]</a>
|
||||||
|
<a href="/juniors" class="active">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Juniors Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-container">
|
||||||
|
<span class="filter-label">search member:</span>
|
||||||
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reconcileBody">
|
||||||
|
{% for row in results %}
|
||||||
|
<tr class="member-row">
|
||||||
|
<td class="member-name">
|
||||||
|
{{ row.name }}
|
||||||
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
|
</td>
|
||||||
|
{% for cell in row.months %}
|
||||||
|
<td title="{{ cell.tooltip }}"
|
||||||
|
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
|
||||||
|
{{ cell.text }}
|
||||||
|
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
||||||
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
|
{% if row.balance < 0 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
{% for t in totals %}
|
||||||
|
<td class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{ t.text }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if credits %}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in credits %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if debts %}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in debts %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div id="qrModal" class="modal"
|
||||||
|
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for ...</div>
|
||||||
|
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="memberModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="closeModal()">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modalStatusBody">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set rt = get_render_time() %}
|
||||||
|
<div class="footer"
|
||||||
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const memberData = {{ member_data| safe }};
|
||||||
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
function showMemberDetails(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = memberData[name];
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
|
||||||
|
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Collect all transactions for listing
|
||||||
|
const allTransactions = [];
|
||||||
|
|
||||||
|
// We need to iterate over months in reverse to show newest first
|
||||||
|
const monthKeys = Object.keys(data.months).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
const mdata = data.months[m];
|
||||||
|
const expected = mdata.expected || 0;
|
||||||
|
const paid = mdata.paid || 0;
|
||||||
|
const attendance = mdata.attendance_count || 0;
|
||||||
|
const originalExpected = mdata.original_expected;
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (expected > 0 || paid > 0) {
|
||||||
|
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
|
||||||
|
else if (paid > 0) { status = paid + '/' + expected; }
|
||||||
|
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCell = mdata.exception
|
||||||
|
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
|
||||||
|
: expected;
|
||||||
|
|
||||||
|
const displayMonth = monthLabels[m] || m;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="color: #888;">${displayMonth}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${attendance}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
|
||||||
|
<td style="text-align: center; color: #ccc;">${paid}</td>
|
||||||
|
<td style="text-align: right;" class="${statusClass}">${status}</td>
|
||||||
|
`;
|
||||||
|
statusBody.appendChild(row);
|
||||||
|
|
||||||
|
if (mdata.transactions) {
|
||||||
|
mdata.transactions.forEach(tx => {
|
||||||
|
allTransactions.push({ month: m, ...tx });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exList = document.getElementById('modalExceptionList');
|
||||||
|
const exSection = document.getElementById('modalExceptionSection');
|
||||||
|
exList.innerHTML = '';
|
||||||
|
|
||||||
|
const exceptions = [];
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
if (data.months[m].exception) {
|
||||||
|
exceptions.push({ month: m, ...data.months[m].exception });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exceptions.length > 0) {
|
||||||
|
exSection.style.display = 'block';
|
||||||
|
exceptions.forEach(ex => {
|
||||||
|
const displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item'; // Reuse style
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${displayMonth}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
|
||||||
|
`;
|
||||||
|
exList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.other_transactions && data.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
data.other_transactions.forEach(tx => {
|
||||||
|
const displayPurpose = tx.purpose || 'Other';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const txList = document.getElementById('modalTxList');
|
||||||
|
txList.innerHTML = '';
|
||||||
|
|
||||||
|
if (allTransactions.length === 0) {
|
||||||
|
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
||||||
|
} else {
|
||||||
|
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
|
||||||
|
const displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
txList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('memberModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
if (id) {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
if (id === 'qrModal') {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('memberModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing filter script
|
||||||
|
document.getElementById('nameFilter').addEventListener('input', function (e) {
|
||||||
|
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
const rows = document.querySelectorAll('.member-row');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const nameNode = row.querySelector('.member-name');
|
||||||
|
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
if (name.includes(filterValue)) {
|
||||||
|
row.style.display = '';
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Esc and Navigate with Arrows
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
closeModal('qrModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('memberModal');
|
||||||
|
if (modal.classList.contains('active')) {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateMember(-1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateMember(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateMember(direction) {
|
||||||
|
const rows = Array.from(document.querySelectorAll('.member-row'));
|
||||||
|
const visibleRows = rows.filter(row => row.style.display !== 'none');
|
||||||
|
|
||||||
|
let currentIndex = visibleRows.findIndex(row => {
|
||||||
|
const nameNode = row.querySelector('.member-name');
|
||||||
|
const name = nameNode.childNodes[0].textContent.trim();
|
||||||
|
return name === currentMemberName;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
let nextIndex = currentIndex + direction;
|
||||||
|
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
|
||||||
|
const nextRow = visibleRows[nextIndex];
|
||||||
|
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
|
||||||
|
showMemberDetails(nextName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showPayQR(name, amount, month, rawMonth) {
|
||||||
|
const account = "{{ bank_account }}";
|
||||||
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
|
const qrImg = document.getElementById('qrImg');
|
||||||
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
|
const qrAmount = document.getElementById('qrAmount');
|
||||||
|
const qrMessage = document.getElementById('qrMessage');
|
||||||
|
|
||||||
|
qrTitle.innerText = `Payment for ${month}`;
|
||||||
|
qrAccount.innerText = account;
|
||||||
|
qrAmount.innerText = amount;
|
||||||
|
qrMessage.innerText = message;
|
||||||
|
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
|
||||||
|
|
||||||
|
qrImg.src = qrUrl;
|
||||||
|
document.getElementById('qrModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target.className === 'modal') {
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
```
|
||||||
@@ -45,8 +45,16 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -67,6 +75,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -137,14 +162,45 @@
|
|||||||
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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
<a href="/adults">[Adults]</a>
|
||||||
<a href="/payments" class="active">[Payments Ledger]</a>
|
<a href="/juniors">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
@@ -183,6 +239,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')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
884
templates/reconcile-juniors.html
Normal file
884
templates/reconcile-juniors.html
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
<!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 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>
|
||||||
|
|
||||||
|
<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 }}', '{{ cell.raw_month }}')">Pay</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
||||||
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
|
{% if row.balance < 0 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if credits %}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in credits %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if debts %}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in debts %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unmatched %}
|
||||||
|
<h2>Unmatched Transactions</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
<div class="unmatched-row unmatched-header">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Sender</span>
|
||||||
|
<span>Message</span>
|
||||||
|
</div>
|
||||||
|
{% for tx in unmatched %}
|
||||||
|
<div class="unmatched-row">
|
||||||
|
<span>{{ tx.date }}</span>
|
||||||
|
<span>{{ tx.amount }}</span>
|
||||||
|
<span>{{ tx.sender }}</span>
|
||||||
|
<span>{{ tx.message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div id="qrModal" class="modal"
|
||||||
|
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for ...</div>
|
||||||
|
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="memberModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalMemberName">Member Name</div>
|
||||||
|
<div class="close-btn" onclick="closeModal()">[close]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Status Summary</div>
|
||||||
|
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
|
||||||
|
<table class="modal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th style="text-align: center;">Att.</th>
|
||||||
|
<th style="text-align: center;">Expected</th>
|
||||||
|
<th style="text-align: center;">Paid</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modalStatusBody">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalExceptionSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Fee Exceptions</div>
|
||||||
|
<div id="modalExceptionList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set rt = get_render_time() %}
|
||||||
|
<div class="footer"
|
||||||
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const memberData = {{ member_data| safe }};
|
||||||
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
function showMemberDetails(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = memberData[name];
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
|
||||||
|
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Collect all transactions for listing
|
||||||
|
const allTransactions = [];
|
||||||
|
|
||||||
|
// We need to iterate over months in reverse to show newest first
|
||||||
|
const monthKeys = Object.keys(data.months).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
const mdata = data.months[m];
|
||||||
|
const expected = mdata.expected || 0;
|
||||||
|
const paid = mdata.paid || 0;
|
||||||
|
const attendance = mdata.attendance_count || 0;
|
||||||
|
const originalExpected = mdata.original_expected;
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (expected > 0 || paid > 0) {
|
||||||
|
if (paid >= expected && expected > 0) { status = '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, rawMonth) {
|
||||||
|
const account = "{{ bank_account }}";
|
||||||
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
|
const qrImg = document.getElementById('qrImg');
|
||||||
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
|
const qrAmount = document.getElementById('qrAmount');
|
||||||
|
const qrMessage = document.getElementById('qrMessage');
|
||||||
|
|
||||||
|
qrTitle.innerText = `Payment for ${month}`;
|
||||||
|
qrAccount.innerText = account;
|
||||||
|
qrAmount.innerText = amount;
|
||||||
|
qrMessage.innerText = message;
|
||||||
|
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
|
||||||
|
|
||||||
|
qrImg.src = qrUrl;
|
||||||
|
document.getElementById('qrModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target.className === 'modal') {
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
```
|
||||||
@@ -45,8 +45,16 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -67,6 +75,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -138,6 +163,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,14 +230,241 @@
|
|||||||
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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
|
<a href="/adults">[Adults]</a>
|
||||||
<a href="/payments">[Payments Ledger]</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 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>Payment Reconciliation</h1>
|
<h1>Payment Reconciliation</h1>
|
||||||
@@ -201,6 +475,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,18 +491,29 @@
|
|||||||
<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 }}', '{{ cell.raw_month }}')">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 %}" 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 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -275,6 +565,320 @@
|
|||||||
</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" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section">
|
||||||
|
<div class="modal-section-title">Payment History</div>
|
||||||
|
<div id="modalTxList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set rt = get_render_time() %}
|
||||||
|
<div class="footer"
|
||||||
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
|
<div id="perf-details" class="perf-breakdown">
|
||||||
|
{{ rt.breakdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const memberData = {{ member_data| safe }};
|
||||||
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
|
function showMemberDetails(name) {
|
||||||
|
currentMemberName = name;
|
||||||
|
const data = memberData[name];
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
document.getElementById('modalMemberName').textContent = name;
|
||||||
|
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
|
||||||
|
|
||||||
|
const statusBody = document.getElementById('modalStatusBody');
|
||||||
|
statusBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Collect all transactions for listing
|
||||||
|
const allTransactions = [];
|
||||||
|
|
||||||
|
// We need to iterate over months in reverse to show newest first
|
||||||
|
const monthKeys = Object.keys(data.months).sort().reverse();
|
||||||
|
|
||||||
|
monthKeys.forEach(m => {
|
||||||
|
const mdata = data.months[m];
|
||||||
|
const expected = mdata.expected || 0;
|
||||||
|
const paid = mdata.paid || 0;
|
||||||
|
const attendance = mdata.attendance_count || 0;
|
||||||
|
const originalExpected = mdata.original_expected;
|
||||||
|
|
||||||
|
let status = '-';
|
||||||
|
let statusClass = '';
|
||||||
|
if (expected > 0 || paid > 0) {
|
||||||
|
if (paid >= expected && expected > 0) { status = '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, rawMonth) {
|
||||||
|
const account = "{{ bank_account }}";
|
||||||
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
|
const qrImg = document.getElementById('qrImg');
|
||||||
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
|
const qrAmount = document.getElementById('qrAmount');
|
||||||
|
const qrMessage = document.getElementById('qrMessage');
|
||||||
|
|
||||||
|
qrTitle.innerText = `Payment for ${month}`;
|
||||||
|
qrAccount.innerText = account;
|
||||||
|
qrAmount.innerText = amount;
|
||||||
|
qrMessage.innerText = message;
|
||||||
|
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
|
||||||
|
|
||||||
|
qrImg.src = qrUrl;
|
||||||
|
document.getElementById('qrModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function (event) {
|
||||||
|
if (event.target.className === 'modal') {
|
||||||
|
event.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
```
|
||||||
157
templates/sync.html
Normal file
157
templates/sync.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ - Sync Bank Data</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container {
|
||||||
|
background-color: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 15px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: {% if success %}#cccccc{% else %}#ff6666{% endif %};
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: #00ff00; }
|
||||||
|
.status-error { color: #ff6666; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults">[Adults]</a>
|
||||||
|
<a href="/juniors">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank" class="active">[Sync Bank Data]</a>
|
||||||
|
<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>
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
|
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||||
|
return fetch_func(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TestWebApp(unittest.TestCase):
|
class TestWebApp(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Configure app for testing
|
|
||||||
app.config['TESTING'] = True
|
app.config['TESTING'] = True
|
||||||
self.client = app.test_client()
|
self.client = app.test_client()
|
||||||
|
|
||||||
@patch('app.get_members_with_fees')
|
def test_index_page(self):
|
||||||
def test_index_page(self, mock_get_members):
|
|
||||||
"""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_members_with_fees')
|
@patch('app.get_members_with_fees')
|
||||||
def test_fees_route(self, mock_get_members):
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
|
||||||
"""Test that /fees returns 200 and renders the dashboard"""
|
"""Test that /fees returns 200 and renders the dashboard"""
|
||||||
# Mock attendance data
|
|
||||||
mock_get_members.return_value = (
|
mock_get_members.return_value = (
|
||||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
['2026-01']
|
['2026-01']
|
||||||
@@ -29,16 +34,36 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
|
||||||
|
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
||||||
|
mock_get_junior_members.return_value = (
|
||||||
|
[
|
||||||
|
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
||||||
|
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/fees-juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
|
||||||
|
self.assertIn(b'Test Junior 1', response.data)
|
||||||
|
self.assertIn(b'? / 1 (J)', response.data)
|
||||||
|
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
@patch('app.get_members_with_fees')
|
@patch('app.get_members_with_fees')
|
||||||
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
|
def test_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
"""Test that /reconcile returns 200 and shows matches"""
|
"""Test that /reconcile returns 200 and shows matches"""
|
||||||
# Mock attendance data
|
|
||||||
mock_get_members.return_value = (
|
mock_get_members.return_value = (
|
||||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
['2026-01']
|
['2026-01']
|
||||||
)
|
)
|
||||||
# Mock sheet data - include all keys required by reconcile
|
|
||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01',
|
'date': '2026-01-01',
|
||||||
'amount': 750,
|
'amount': 750,
|
||||||
@@ -55,10 +80,10 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
self.assertIn(b'OK', response.data)
|
self.assertIn(b'OK', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
def test_payments_route(self, mock_fetch_sheet):
|
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
||||||
"""Test that /payments returns 200 and groups transactions"""
|
"""Test that /payments returns 200 and groups transactions"""
|
||||||
# Mock sheet data
|
|
||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01',
|
'date': '2026-01-01',
|
||||||
'amount': 750,
|
'amount': 750,
|
||||||
@@ -67,12 +92,103 @@ class TestWebApp(unittest.TestCase):
|
|||||||
'message': 'Direct Member Payment',
|
'message': 'Direct Member Payment',
|
||||||
'sender': 'External Bank User'
|
'sender': 'External Bank User'
|
||||||
}]
|
}]
|
||||||
|
|
||||||
response = self.client.get('/payments')
|
response = self.client.get('/payments')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(b'Payments Ledger', response.data)
|
self.assertIn(b'Payments Ledger', response.data)
|
||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
self.assertIn(b'Direct Member Payment', response.data)
|
self.assertIn(b'Direct Member Payment', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions')
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
|
||||||
|
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
||||||
|
mock_get_junior.return_value = (
|
||||||
|
[
|
||||||
|
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
|
||||||
|
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_exceptions.return_value = {}
|
||||||
|
mock_transactions.return_value = [{
|
||||||
|
'date': '2026-01-15',
|
||||||
|
'amount': 500,
|
||||||
|
'person': 'Junior One',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'message': '',
|
||||||
|
'sender': 'Parent',
|
||||||
|
'inferred_amount': 500
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.get('/reconcile-juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Junior Payment Reconciliation', response.data)
|
||||||
|
self.assertIn(b'Junior One', response.data)
|
||||||
|
self.assertIn(b'Junior Two', response.data)
|
||||||
|
self.assertIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test that /adults returns 200 and shows combined matches"""
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01',
|
||||||
|
'amount': 750,
|
||||||
|
'person': 'Test Member',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'message': 'test payment',
|
||||||
|
'sender': 'External Bank User',
|
||||||
|
'inferred_amount': 750
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.get('/adults')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Adults Dashboard', response.data)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
self.assertNotIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'750/750 CZK (4)', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
def test_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test that /juniors returns 200, uses single line format, and displays '?' properly"""
|
||||||
|
mock_get_junior_members.return_value = (
|
||||||
|
[
|
||||||
|
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||||
|
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_exceptions.return_value = {}
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-15',
|
||||||
|
'amount': 500,
|
||||||
|
'person': 'Junior One',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'message': '',
|
||||||
|
'sender': 'Parent',
|
||||||
|
'inferred_amount': 500
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.get('/juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Juniors Dashboard', response.data)
|
||||||
|
self.assertIn(b'Junior One', response.data)
|
||||||
|
self.assertIn(b'Junior Two', response.data)
|
||||||
|
self.assertNotIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'500/500 CZK', response.data)
|
||||||
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
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()
|
||||||
240
uv.lock
generated
240
uv.lock
generated
@@ -127,6 +127,75 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.5"
|
version = "46.0.5"
|
||||||
@@ -199,13 +268,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.2"
|
version = "0.10"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "google-api-python-client" },
|
{ name = "google-api-python-client" },
|
||||||
{ name = "google-auth-httplib2" },
|
{ name = "google-auth-httplib2" },
|
||||||
{ name = "google-auth-oauthlib" },
|
{ name = "google-auth-oauthlib" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -214,6 +291,14 @@ requires-dist = [
|
|||||||
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
||||||
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
||||||
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
||||||
|
{ name = "gunicorn", specifier = ">=23.0" },
|
||||||
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -300,6 +385,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httplib2"
|
name = "httplib2"
|
||||||
version = "0.31.2"
|
version = "0.31.2"
|
||||||
@@ -321,6 +418,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -403,6 +509,82 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proto-plus"
|
name = "proto-plus"
|
||||||
version = "1.27.1"
|
version = "1.27.1"
|
||||||
@@ -460,6 +642,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
@@ -469,6 +660,53 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
pil = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.32.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user