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.domain.contributions import ( CLAIM_STATUS_LABELS, claim_status, claim_total, money_text, payment_allocated_total, ) from ccma.domain.dates import age_label, date_input_hint, format_date_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.dialogs import IntegrityWarningDialog from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.payment_dialog import PaymentEditDialog from ccma.ui.scrolling import ScrollableFrame CLAIM_TABLE_COLUMNS = ( ("title", "Forderung", 220), ("due", "Fällig", 100), ("amount", "Betrag", 90), ("status", "Status", 110), ) 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, 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.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=3) 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: self.pane.sashpos(0, max(360, int(width * 0.4))) 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) payments_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(payments_tab, text="Zahlungen") 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=("title", "due", "amount", "status"), 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") 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()) 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", 100), ("amount", "Betrag", 90), ("allocated", "Zugeordnet", 90), ("available", "Frei", 90), ("reference", "Referenz", 320), ): self.payments.heading(key, text=title) self.payments.column(key, width=width, anchor="w") 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 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" ) 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_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=( claim.get("title", "Beitrag"), format_date_for_display(str(claim.get("due_date", ""))), money_text(claim_total(claim)), CLAIM_STATUS_LABELS.get(status, status.upper()), ), ) 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 _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 _payment_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"