Files
CCMA/src/ccma/ui/member_tab.py
T
Marcel PeterkauandClaude Sonnet 5 a1fb2f4e30 Refresh the member tab after a single-member housekeeper run
_execute_housekeeper_run() showed the success dialog but never called
refresh()/on_changed(), so retroactively created claims/payments stayed
invisible in the currently open Mitgliedsakte until it was closed and
reopened, and the overview list wasn't notified either. Match the pattern
used by every other mutating action in this file (save, payment, credit,
reminder, ...): refresh the tab's own contribution lists and notify the
overview callback right after the run succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:40:40 +02:00

1213 lines
51 KiB
Python

from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from tkinter import messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
CONTRIBUTION_OVERRIDE_KIND_LABELS,
DONATION_STATUS_LABELS,
claim_status,
claim_total,
donation_allocated_total,
donation_balance,
donation_status,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.donation_dialog import (
AllocateDonationPaymentDialog,
DonationEditDialog,
DonationPaymentDialog,
)
from ccma.ui.file_open import open_path
from ccma.ui.gnucash_import_dialog import GnuCashImportDialog
from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
# (they tend to hold longer label text), and only the trailing description/reference
# column stretches to take up whatever space remains.
FIXED_COLUMN_WIDTH = 90
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)
CLAIM_TABLE_COLUMNS = (
("due", "Fällig", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("title", "Forderung", 220),
)
def _claim_sort_value(data, claim: dict, column: str) -> str:
if column == "title":
return str(claim.get("title", ""))
if column == "due":
return str(claim.get("due_date", ""))
if column == "amount":
return f"{claim_total(claim):012.2f}"
if column == "status":
status = claim_status(data, claim)
return CLAIM_STATUS_LABELS.get(status, status.upper())
return ""
class _RunHousekeeperDialog(tk.Toplevel):
"""Confirms a housekeeper run scoped to a single member, with the option to
force retroactive claim creation for just this run without touching the
application-wide default setting."""
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
super().__init__(master)
self.on_confirm = on_confirm
self.title("Hausmeister für dieses Mitglied ausführen")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text=(
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
"Andere Mitglieder bleiben unberührt."
),
wraplength=380,
justify="left",
).pack(anchor="w", pady=(0, 12))
self.retroactive_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
frame,
text="Rückwirkende Beiträge/Forderungen erstellen",
variable=self.retroactive_var,
).pack(anchor="w", pady=(0, 14))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
side="left"
)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _confirm(self) -> None:
retroactive = self.retroactive_var.get()
self.destroy()
self.on_confirm(retroactive)
class MemberTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
config: AppConfig,
on_close: Callable[[], None],
on_changed: Callable[[], None],
on_open_claim: Callable[[str, str], None],
on_open_assets: Callable[[], None],
on_open_asset: Callable[[str], None],
on_return_asset: Callable[[str], None],
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
):
super().__init__(master, padding=12)
self.repository = repository
self.member_id = member_id
self.config = config
self.on_close = on_close
self.on_changed = on_changed
self.on_open_claim = on_open_claim
self.on_open_assets = on_open_assets
self.on_open_asset = on_open_asset
self.on_return_asset = on_return_asset
self.on_run_housekeeper = on_run_housekeeper
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
self._dirty_sections: set[str] = set()
self._form_tabs: dict[str, str] = {}
self._form_tab_titles: dict[str, str] = {}
self._loading = False
self.notes_text: tk.Text | None = None
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar()
self.status_var = tk.StringVar()
self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 0)
)
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
header_actions = ttk.Frame(header)
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
ttk.Button(
header_actions,
text="Hausmeister für dieses Mitglied",
command=self._run_housekeeper_for_member,
).pack(side="left", padx=(0, 8))
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=2, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
self.pane.add(self.timeline_pane, weight=1)
self._build_details(self.details_pane)
self._build_timeline(self.timeline_pane)
self._pane_position_initialized = False
self.pane.bind("<Configure>", self._set_initial_pane_position, add="+")
def _set_initial_pane_position(self, event: tk.Event | None = None) -> None:
if self._pane_position_initialized:
return
try:
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
if width > 1:
# Chronik (timeline_pane) defaults to a third of the available width.
self.pane.sashpos(0, max(360, round(width * 2 / 3)))
self._pane_position_initialized = True
except tk.TclError:
return
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent)
self.details_notebook = notebook
notebook.grid(row=1, column=0, sticky="nsew")
data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
address_tab = self._create_form_tab(notebook, "address", "Anschrift")
banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
ttk.Button(
actions,
text="Mitgliedsdaten speichern",
style="Accent.TButton",
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16)
contribution_override_tab = ttk.Frame(notebook, padding=16)
payments_tab = ttk.Frame(notebook, padding=16)
donations_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(contribution_tab, text="Forderungen")
notebook.add(contribution_override_tab, text="Beitrag")
notebook.add(payments_tab, text="Zahlungen")
notebook.add(donations_tab, text="Spenden")
notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente")
fields = [
("UUID / Ordner-ID", "member_id"),
("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"),
("Nachname", "last_name"),
("Nickname", "nickname"),
("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Antragsdatum ({date_input_hint()})", "application_date"),
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
]
for row, (label, key) in enumerate(fields):
variable = tk.StringVar()
self._add_variable(key, variable, "data")
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date":
birth_row = ttk.Frame(data_tab)
birth_row.grid(row=row, column=1, sticky="ew", pady=5)
birth_row.columnconfigure(0, weight=1)
ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew")
self.age_var = tk.StringVar(value="Alter: —")
ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid(
row=0, column=1, sticky="w", padx=(10, 0)
)
variable.trace_add(
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
)
else:
entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
row=row, column=1, sticky="ew", pady=5
)
self._add_variable("status", tk.StringVar(), "data")
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
data_tab,
textvariable=self.variables["status"],
values=list(STATUS_LABELS.values()),
state="readonly",
width=39,
).grid(row=len(fields), column=1, sticky="ew", pady=5)
ttk.Label(data_tab, text="Interne Notiz").grid(
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
)
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
data_tab.columnconfigure(1, weight=1)
address_fields = (
("Straße und Hausnummer", "street"),
("Adresszusatz", "address_addition"),
("Postleitzahl", "postal_code"),
("Ort", "city"),
("Land", "country"),
)
address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields):
self._add_variable(key, tk.StringVar(), "address")
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
banking_fields = (
("Kontoinhaber", "account_holder"),
("IBAN", "iban"),
("BIC", "bic"),
("Mandatsreferenz", "mandate_reference"),
(f"Mandat erteilt am ({date_input_hint()})", "mandate_signed_at"),
(f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
)
banking_tab.columnconfigure(1, weight=1)
self._add_variable("payment_frequency", tk.StringVar(), "banking")
ttk.Label(banking_tab, text="Zahlweise").grid(
row=0, column=0, sticky="w", padx=(0, 12), pady=5
)
ttk.Combobox(
banking_tab,
textvariable=self.variables["payment_frequency"],
values=list(PAYMENT_FREQUENCY_LABELS.values()),
state="readonly",
width=39,
).grid(row=0, column=1, sticky="ew", pady=5)
for row, (label, key) in enumerate(banking_fields, start=1):
self._add_variable(key, tk.StringVar(), "banking")
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self._add_variable("mandate_active", tk.BooleanVar(), "banking")
ttk.Checkbutton(
banking_tab,
text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"],
style="Switch",
).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar()
ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.claims = ttk.Treeview(
contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
)
self.claim_sort_column = "due"
self.claim_sort_descending = False
for key, title, width in CLAIM_TABLE_COLUMNS:
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
self.claims.column(key, width=width, anchor="w", stretch=key == "title")
self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
contribution_override_tab.columnconfigure(0, weight=1)
contribution_override_tab.rowconfigure(1, weight=1)
ttk.Label(
contribution_override_tab,
text=(
"Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
"den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
"Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
),
style="Mono.TLabel",
wraplength=700,
).grid(row=0, column=0, sticky="w", pady=(0, 10))
self.contribution_overrides = ttk.Treeview(
contribution_override_tab,
columns=("valid_from", "valid_until", "kind", "value", "reason"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("valid_from", "Ab", FIXED_COLUMN_WIDTH),
("valid_until", "Bis", FIXED_COLUMN_WIDTH),
("kind", "Art", FIXED_COLUMN_WIDTH),
("value", "Wert", FIXED_COLUMN_WIDTH),
("reason", "Begründung", 320),
):
self.contribution_overrides.heading(key, text=title)
self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
self.contribution_overrides.bind(
"<Double-1>", lambda _event: self._edit_selected_contribution_override()
)
self.contribution_overrides.bind(
"<Return>", lambda _event: self._edit_selected_contribution_override()
)
contribution_override_actions = ttk.Frame(contribution_override_tab)
contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
contribution_override_actions,
text="Abweichenden Beitrag anlegen",
command=self._create_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Bearbeiten",
command=self._edit_selected_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Löschen",
command=self._delete_selected_contribution_override,
).pack(side="left")
payments_tab.columnconfigure(0, weight=1)
payments_tab.rowconfigure(1, weight=1)
self.payment_summary = tk.StringVar()
ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.payments = ttk.Treeview(
payments_tab,
columns=("date", "amount", "allocated", "available", "reference"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("available", "Frei", FIXED_COLUMN_WIDTH),
("reference", "Referenz", 320),
):
self.payments.heading(key, text=title)
self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
self.payments.grid(row=1, column=0, sticky="nsew")
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
payment_actions = ttk.Frame(payments_tab)
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
side="left"
)
ttk.Separator(payment_actions, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Button(
payment_actions, text="Zahlungen importieren", command=self._import_payments
).pack(side="left")
donations_tab.columnconfigure(0, weight=1)
donations_tab.rowconfigure(1, weight=1)
self.donation_summary = tk.StringVar()
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.donations = ttk.Treeview(
donations_tab,
columns=("date", "amount", "allocated", "balance", "status", "reference"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("balance", "Offen", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("reference", "Referenz / Zweck", 260),
):
self.donations.heading(key, text=title)
self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
self.donations.grid(row=1, column=0, sticky="nsew")
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
donation_actions = ttk.Frame(donations_tab)
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
ttk.Button(
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
).pack(side="left", padx=(0, 8))
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
side="left"
)
assets_tab.columnconfigure(0, weight=1)
assets_tab.rowconfigure(1, weight=1)
self.assets_summary = tk.StringVar()
ttk.Label(assets_tab, textvariable=self.assets_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.assets_tree = ttk.Treeview(
assets_tab,
columns=("label", "category", "inventory_number", "status"),
show="headings",
)
for key, title, width in (
("label", "Bezeichnung", 240),
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
):
self.assets_tree.heading(key, text=title)
self.assets_tree.column(key, width=width, anchor="w")
self.assets_tree.grid(row=1, column=0, sticky="nsew")
self.assets_tree.bind("<Double-1>", lambda _event: self._open_selected_asset())
self.assets_tree.bind("<Return>", lambda _event: self._open_selected_asset())
asset_actions = ttk.Frame(assets_tab)
asset_actions.grid(row=2, column=0, sticky="e", pady=(10, 0))
ttk.Button(asset_actions, text="Inventar öffnen", command=self.on_open_assets).pack(
side="left", padx=(0, 8)
)
ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack(
side="left", padx=(0, 8)
)
ttk.Button(
asset_actions,
text="Ausgewähltes Asset zurücknehmen",
command=self._return_selected_asset,
).pack(
side="left",
)
documents_tab.columnconfigure(0, weight=1)
documents_tab.rowconfigure(1, weight=1)
document_buttons = ttk.Frame(documents_tab)
document_buttons.grid(row=0, column=0, sticky="ew", pady=(0, 10))
ttk.Button(
document_buttons,
text="Dokument aus Template",
style="Accent.TButton",
command=self._create_document,
).pack(side="left", padx=(0, 8))
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
self.documents = ttk.Treeview(
documents_tab,
columns=("name", "type", "modified", "size"),
show="headings",
)
for key, title, width in (
("name", "Dokument", 280),
("type", "Typ", 65),
("modified", "Geändert", 135),
("size", "Größe", 80),
):
self.documents.heading(key, text=title)
self.documents.column(key, width=width, anchor="w")
self.documents.grid(row=1, column=0, sticky="nsew")
document_scroll = ttk.Scrollbar(
documents_tab,
orient="vertical",
command=self.documents.yview,
)
document_scroll.grid(row=1, column=1, sticky="ns")
self.documents.configure(yscrollcommand=document_scroll.set)
self.documents.bind("<Double-1>", lambda _event: self._open_selected_document())
self.documents.bind("<Return>", lambda _event: self._open_selected_document())
self.document_paths: dict[str, Path] = {}
def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
self._form_tabs[section] = str(tab)
self._form_tab_titles[section] = title
return scroller.content
def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
self.variables[key] = variable
self._field_sections[key] = section
variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))
def _mark_dirty(self, section: str) -> None:
if self._loading:
return
self._dirty_sections.add(section)
self._refresh_dirty_tabs()
def _mark_dirty_from_text(self, section: str) -> None:
if self.notes_text is None or not self.notes_text.edit_modified():
return
self.notes_text.edit_modified(False)
self._mark_dirty(section)
def _refresh_dirty_tabs(self) -> None:
for section, tab_id in self._form_tabs.items():
title = self._form_tab_titles[section]
suffix = " *" if section in self._dirty_sections else ""
self.details_notebook.tab(tab_id, text=f"{title}{suffix}")
def _clear_dirty(self) -> None:
self._dirty_sections.clear()
self._refresh_dirty_tabs()
def _close(self) -> None:
if self._dirty_sections and not messagebox.askokcancel(
"Ungespeicherte Änderungen",
"Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
parent=self,
):
return
self.on_close()
def _run_housekeeper_for_member(self) -> None:
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
def _execute_housekeeper_run(self, retroactive: bool) -> None:
try:
findings = self.on_run_housekeeper(self.member_id, retroactive)
except RepositoryError as exc:
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
return
member_findings = [item for item in findings if item.member_id == self.member_id]
messagebox.showinfo(
"Hausmeister ausgeführt",
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
parent=self,
)
self.refresh()
self.on_changed()
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
self.timeline = ttk.Treeview(
parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview"
)
self.timeline.heading("time", text="Zeit")
self.timeline.heading("summary", text="Ereignis")
self.timeline.column("time", width=135, stretch=False)
self.timeline.column("summary", width=320, stretch=True)
self.timeline.grid(row=1, column=0, sticky="nsew")
compose = ttk.Frame(parent)
compose.grid(row=2, column=0, sticky="ew", pady=(10, 0))
compose.columnconfigure(0, weight=1)
self.comment_var = tk.StringVar()
comment = ttk.Entry(compose, textvariable=self.comment_var)
comment.grid(row=0, column=0, sticky="ew", padx=(0, 6))
comment.bind("<Return>", lambda _event: self._add_comment())
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None:
self._loading = True
self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}")
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
self.id_var.set(f"UUID: {self.member.member_id}")
warnings = self.repository.member_hash_warnings(self.member_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
date_fields = {
"birth_date",
"application_date",
"accepted_at",
"membership_started_at",
"membership_ended_at",
"mandate_signed_at",
"mandate_revoked_at",
}
for key, variable in self.variables.items():
value = getattr(self.member, key)
if key == "status":
variable.set(display_label(STATUS_LABELS, str(value)))
elif key == "payment_frequency":
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
else:
variable.set(format_date_for_display(value) if key in date_fields else value)
if self.notes_text is not None:
self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.member.notes)
self.notes_text.edit_modified(False)
self._loading = False
self._clear_dirty()
self._refresh_events()
self._refresh_contributions()
self._refresh_contribution_overrides()
self._refresh_donations()
self._refresh_assets()
self._refresh_documents()
def _refresh_events(self) -> None:
self.timeline.delete(*self.timeline.get_children())
try:
events = self.repository.get_events(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Chronik beschädigt", str(exc), parent=self)
return
for event in reversed(events):
self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))
def _refresh_contributions(self) -> None:
self.claims.delete(*self.claims.get_children())
self.payments.delete(*self.payments.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
self.contribution_summary.set(f"FEHLER: {exc}")
self.payment_summary.set(f"FEHLER: {exc}")
return
claims = sorted(
data.claims,
key=lambda claim: _claim_sort_value(data, claim, self.claim_sort_column).casefold(),
reverse=self.claim_sort_descending,
)
self._update_claim_headings()
for index, claim in enumerate(claims):
claim_id = str(claim.get("claim_id") or f"missing-id-{index}")
status = claim_status(data, claim)
self.claims.insert(
"",
"end",
iid=claim_id,
values=(
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_total(claim)),
CLAIM_STATUS_LABELS.get(status, status.upper()),
claim.get("title", "Beitrag"),
),
)
for payment in sorted(
data.payments,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
payment_id = str(payment.get("payment_id", ""))
amount = payment.get("amount", "0")
allocated = payment_allocated_total(data, payment_id)
available = max(Decimal(str(amount)) - allocated, Decimal("0"))
self.payments.insert(
"",
"end",
iid=payment_id,
values=(
format_date_for_display(str(payment.get("date", ""))),
f"{money_text(amount)} EUR",
f"{money_text(allocated)} EUR",
f"{money_text(available)} EUR",
payment.get("reference", ""),
),
)
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
total_allocated = sum(
(payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
Decimal("0"),
)
self.payment_summary.set(
f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
)
def _refresh_donations(self) -> None:
self.donations.delete(*self.donations.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
self.donation_summary.set(f"FEHLER: {exc}")
return
for donation in sorted(
data.donations,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
donation_id = str(donation.get("donation_id", ""))
amount = donation.get("amount", "0")
allocated = donation_allocated_total(data, donation_id)
balance = donation_balance(data, donation)
status = donation_status(data, donation)
reference = " · ".join(
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
)
self.donations.insert(
"",
"end",
iid=donation_id,
values=(
format_date_for_display(str(donation.get("date", ""))),
f"{money_text(amount)} EUR",
f"{money_text(allocated)} EUR",
f"{money_text(balance)} EUR",
DONATION_STATUS_LABELS.get(status, status.upper()),
reference,
),
)
total_amount = sum(
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
)
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
def _refresh_contribution_overrides(self) -> None:
self.contribution_overrides.delete(*self.contribution_overrides.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError:
return
for override in sorted(
data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
):
kind = str(override.get("kind", ""))
value = str(override.get("value", ""))
value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
self.contribution_overrides.insert(
"",
"end",
iid=str(override.get("override_id", "")),
values=(
format_month_for_display(str(override.get("valid_from", ""))),
format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
value_text,
override.get("reason", ""),
),
)
def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column:
self.claim_sort_descending = not self.claim_sort_descending
else:
self.claim_sort_column = column
self.claim_sort_descending = False
self._refresh_contributions()
def _update_claim_headings(self) -> None:
for key, title, _width in CLAIM_TABLE_COLUMNS:
suffix = ""
if key == self.claim_sort_column:
suffix = " v" if self.claim_sort_descending else " ^"
self.claims.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_claim_sort(column),
)
def _open_selected_claim(self) -> None:
selected = self.claims.selection()
if selected and not selected[0].startswith("missing-id-"):
self.on_open_claim(self.member_id, selected[0])
def _edit_selected_payment(self) -> None:
selected = self.payments.selection()
if not selected:
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
try:
PaymentEditDialog(
self,
self.repository,
self.member_id,
selected[0],
self._payment_changed,
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)
def _delete_selected_payment(self) -> None:
selected = self.payments.selection()
if not selected:
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
values = self.payments.item(selected[0], "values")
label = f"{values[0]} · {values[1]}" if values else selected[0]
if not messagebox.askyesno(
"Zahlung wirklich löschen?",
f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
parent=self,
):
return
try:
self.repository.delete_payment(self.member_id, selected[0])
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
return
self._payment_changed()
def _create_payment(self) -> None:
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
def _import_payments(self) -> None:
GnuCashImportDialog(
self, self.repository, self.member_id, self.config, self._payment_changed
)
def _payment_changed(self) -> None:
self.refresh()
self.on_changed()
def _selected_donation(self) -> dict | None:
selected = self.donations.selection()
if not selected:
return None
try:
_data, donation = self.repository.get_donation(self.member_id, selected[0])
except RepositoryError:
return None
return donation
def _create_donation(self) -> None:
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
def _edit_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
def _delete_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
if allocated:
detail += (
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
"gelöst und stehen danach wieder frei zur Verfügung."
)
if not messagebox.askyesno("Spende löschen", detail, parent=self):
return
try:
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._donation_changed()
def _record_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
DonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _allocate_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
AllocateDonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _donation_changed(self) -> None:
self.refresh()
self.on_changed()
def _selected_contribution_override(self) -> dict | None:
selected = self.contribution_overrides.selection()
if not selected:
return None
try:
_data, override = self.repository.get_contribution_override(self.member_id, selected[0])
except RepositoryError:
return None
return override
def _create_contribution_override(self) -> None:
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed
)
def _edit_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed, override
)
def _delete_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
if not messagebox.askyesno(
"Abweichenden Beitrag löschen",
"Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
"davon unberührt.",
parent=self,
):
return
try:
self.repository.delete_contribution_override(
self.member_id, str(override.get("override_id", ""))
)
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._contribution_override_changed()
def _contribution_override_changed(self) -> None:
self.refresh()
self.on_changed()
def _refresh_documents(self) -> None:
self.documents.delete(*self.documents.get_children())
self.document_paths.clear()
root = self.repository.members_root / self.member_id / "files"
for index, path in enumerate(sorted(root.rglob("*"))):
if path.is_file():
item_id = f"document:{index}"
self.document_paths[item_id] = path
try:
stat = path.stat()
modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M")
size = _file_size(stat.st_size)
except OSError:
modified = "—"
size = "—"
self.documents.insert(
"",
"end",
iid=item_id,
values=(
path.relative_to(root),
path.suffix.removeprefix(".").upper() or "DATEI",
modified,
size,
),
)
def _refresh_assets(self) -> None:
self.assets_tree.delete(*self.assets_tree.get_children())
held_assets = self.repository.list_member_assets(self.member_id)
owned_assets = self.repository.list_member_owned_assets(self.member_id)
assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
assets = list(assets_by_id.values())
self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
for asset in assets:
self.assets_tree.insert(
"",
"end",
iid=asset.asset_id,
values=(
asset.label,
asset.category,
asset.inventory_number,
(
"EIGENTUM · DEM VEREIN ÜBERLASSEN"
if asset.owner_member_id == self.member_id and asset.custody_type == "club"
else "EIGENTUM"
if asset.owner_member_id == self.member_id
else ASSET_STATUS_LABELS.get(asset.status, asset.status)
),
),
)
def _save(self) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
for key, variable in self.variables.items():
raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
if key == "status":
value = storage_key(STATUS_LABELS, value)
elif key == "payment_frequency":
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
setattr(self.member, key, value)
if self.notes_text is not None:
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
try:
self.repository.save_member(self.member)
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text:
return
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
self.comment_var.set("")
self._refresh_events()
def _open_files(self) -> None:
path = self.repository.members_root / self.member_id / "files"
self._open_path(path)
def _return_selected_asset(self) -> None:
selected = self.assets_tree.selection()
if selected:
self.on_return_asset(selected[0])
def _open_selected_asset(self) -> None:
selected = self.assets_tree.selection()
if selected:
self.on_open_asset(selected[0])
def _open_selected_document(self) -> None:
selected = self.documents.selection()
if selected and selected[0] in self.document_paths:
self._open_path(self.document_paths[selected[0]])
def _open_path(self, path: Path) -> None:
try:
open_path(path)
except OSError as exc:
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
def _create_document(self) -> None:
DocumentTemplateDialog(
self,
self.repository,
self.member_id,
self._document_generated,
)
def _document_generated(self, _path: Path) -> None:
self._refresh_documents()
self._refresh_events()
self.on_changed()
def _format_timestamp(event: Event) -> str:
try:
return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M")
except ValueError:
return event.timestamp[:16]
def _event_label(event: Event) -> str:
if event.actor_type == "system":
return f"[AUTO] {event.summary}"
return event.summary
def _file_size(size: int) -> str:
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KiB"
return f"{size / (1024 * 1024):.1f} MiB"