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 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 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], ): 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.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) ) ttk.Button(header, text="Tab schließen", command=self._close).grid( row=0, column=1, sticky="ne", padx=(12, 0) ) 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("", 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("<>", 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("", lambda _event: self._open_selected_claim()) self.claims.bind("", 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( "", lambda _event: self._edit_selected_contribution_override() ) self.contribution_overrides.bind( "", 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("", lambda _event: self._edit_selected_payment()) self.payments.bind("", 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("", lambda _event: self._edit_selected_donation()) self.donations.bind("", 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("", lambda _event: self._open_selected_asset()) self.assets_tree.bind("", 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("", lambda _event: self._open_selected_document()) self.documents.bind("", 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 _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("", 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"