from __future__ import annotations import tkinter as tk from collections.abc import Callable from datetime import datetime from pathlib import Path from tkinter import messagebox, ttk from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.dates import age_label, date_input_hint, format_date_for_display from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import Event from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key 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], ): 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.member = repository.get_member(member_id) self.variables: dict[str, tk.Variable] = {} self.notes_text: tk.Text | None = None self._build_ui() self.refresh() def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) header = ttk.Frame(self) header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.columnconfigure(0, weight=1) self.title_var = tk.StringVar() self.status_var = tk.StringVar() ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid( row=1, column=0, sticky="w", pady=(3, 0) ) ttk.Button(header, text="Tab schließen", command=self.on_close).grid( row=0, column=1, rowspan=2, sticky="e" ) self.pane = ttk.Panedwindow(self, orient="horizontal") self.pane.grid(row=1, 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(0, weight=1) notebook = ttk.Notebook(parent) notebook.grid(row=0, column=0, sticky="nsew") data_tab = ttk.Frame(notebook, padding=16) address_tab = ttk.Frame(notebook, padding=16) banking_tab = ttk.Frame(notebook, padding=16) contribution_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16) notebook.add(data_tab, text="Stammdaten") notebook.add(address_tab, text="Anschrift") notebook.add(banking_tab, text="Bank / SEPA") notebook.add(contribution_tab, text="Forderungen") notebook.add(documents_tab, text="Dokumente") fields = [ ("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"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), (f"Mitglied seit ({date_input_hint()})", "membership_started_at"), ] for row, (label, key) in enumerate(fields): variable = tk.StringVar() self.variables[key] = variable 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 == "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.variables["status"] = tk.StringVar() 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) data_tab.columnconfigure(1, weight=1) ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid( row=len(fields) + 2, column=1, sticky="e", pady=(18, 0) ) 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.variables[key] = tk.StringVar() 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 ) ttk.Button( address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save ).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0)) 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) for row, (label, key) in enumerate(banking_fields): self.variables[key] = tk.StringVar() 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.variables["mandate_active"] = tk.BooleanVar() ttk.Checkbutton( banking_tab, text="SEPA-Lastschriftmandat ist aktiv", variable=self.variables["mandate_active"], style="Switch", ).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5)) ttk.Button( banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save ).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0)) 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()) 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 _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.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())) date_fields = { "birth_date", "accepted_at", "membership_started_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))) 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._refresh_events() self._refresh_contributions() 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()) try: data = self.repository.get_contributions(self.member_id) except RepositoryError as exc: self.contribution_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()), ), ) self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") 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 _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 _save(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) 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) except RepositoryError as exc: messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) return self.refresh() self.on_changed() 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 _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"