import tkinter as tk from collections import Counter from collections.abc import Callable from tkinter import messagebox, ttk from ccma.domain.dates import format_date_for_display from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member from ccma.ui.labels import storage_key MEMBER_TABLE_COLUMNS = ( ("number", "Nummer", 110), ("first_name", "Vorname", 160), ("last_name", "Nachname", 180), ("nickname", "Nickname", 160), ("email", "E-Mail-Adresse", 270), ("birth", "Geburtsdatum", 120), ("status", "Status", 170), ) STATUS_FILTER_ALL = "Alle" def _member_table_value(member: Member, column: str) -> str: if column == "number": return member.member_number if column == "first_name": return member.first_name if column == "last_name": return member.last_name if column == "nickname": return member.nickname if column == "email": return member.email if column == "birth": return member.birth_date if column == "status": return MEMBERSHIP_STATUS_LABELS.get(member.status, member.status) return "" def _filter_members(members: list[Member], status_filter: str) -> list[Member]: if status_filter == "all": return list(members) return [member for member in members if member.status == status_filter] def _sort_members(members: list[Member], column: str, descending: bool) -> list[Member]: return sorted( members, key=lambda member: _member_table_value(member, column).casefold(), reverse=descending, ) def _selected_status_filter(label: str) -> str: if label == STATUS_FILTER_ALL: return "all" return storage_key(MEMBERSHIP_STATUS_LABELS, label) class DashboardTab(ttk.Frame): def __init__( self, master: tk.Misc, member_count: int, findings: list[HousekeeperFinding], on_housekeeper: Callable[[], None], ): super().__init__(master, padding=24) self.member_count = member_count self.findings = findings self.on_housekeeper = on_housekeeper self._build_ui() def _build_ui(self) -> None: self.columnconfigure(0, weight=1) ttk.Label(self, text="SYSTEM OVERVIEW", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") ttk.Label(self, text="Mitgliederverwaltung · lokaler File-Store", style="Mono.TLabel").grid( row=1, column=0, sticky="w", pady=(3, 22) ) cards = ttk.Frame(self) cards.grid(row=2, column=0, sticky="ew") counts = Counter(finding.severity for finding in self.findings) values = [ ("MITGLIEDER", str(self.member_count), ""), ("ACTION REQUIRED", str(counts["error"]), "CardError.TLabel"), ("DUE SOON", str(counts["warning"] + counts["info"]), "CardWarning.TLabel"), ("DATA INTEGRITY", "OK", ""), ] for column, (label, value, style) in enumerate(values): card = ttk.Frame(cards, style="Card.TFrame", padding=18) card.grid(row=0, column=column, sticky="nsew", padx=(0, 10)) cards.columnconfigure(column, weight=1) ttk.Label(card, text=label, style="CardTitle.TLabel").pack(anchor="w") ttk.Label(card, text=value, style=style or "CardValue.TLabel").pack(anchor="w", pady=(8, 0)) ttk.Button(self, text="Hausmeister öffnen", style="Accent.TButton", command=self.on_housekeeper).grid( row=3, column=0, sticky="w", pady=(24, 0) ) def update_data(self, member_count: int, findings: list[HousekeeperFinding]) -> None: self.member_count = member_count self.findings = findings for child in self.winfo_children(): child.destroy() self._build_ui() class SearchResultsTab(ttk.Frame): def __init__( self, master: tk.Misc, query: str, members: list[Member], on_open: Callable[[str], None], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.query = query self.members = members self.on_open = on_open self.on_close = on_close self._build_ui() 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) ttk.Label(header, text=f'Suche: "{self.query}"', style="TabTitle.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid( row=1, column=0, sticky="w" ) ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2) tree = ttk.Treeview( self, columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"), show="headings", ) for key, title, width in ( ("number", "Nummer", 90), ("first_name", "Vorname", 150), ("last_name", "Nachname", 170), ("nickname", "Nickname", 150), ("email", "E-Mail-Adresse", 260), ("birth", "Geburtsdatum", 110), ("status", "Status", 160), ): tree.heading(key, text=title) tree.column(key, width=width, anchor="w") tree.grid(row=1, column=0, sticky="nsew") for member in self.members: tree.insert( "", "end", iid=member.member_id, values=( member.member_number, member.first_name, member.last_name, member.nickname, member.email, format_date_for_display(member.birth_date), MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), ), ) tree.bind("", lambda _event: self._open_selected(tree)) tree.bind("", lambda _event: self._open_selected(tree)) def _open_selected(self, tree: ttk.Treeview) -> None: selected = tree.selection() if selected: self.on_open(selected[0]) class MembersTab(ttk.Frame): def __init__( self, master: tk.Misc, members: list[Member], on_open: Callable[[str], None], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.members = members self.on_open = on_open self.on_close = on_close self._build_ui() 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) ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") self.count_var = tk.StringVar() ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2) filters = ttk.Frame(self, padding=(12, 10)) filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) filters.columnconfigure(1, weight=1) ttk.Label(filters, text="Filter", style="Mono.TLabel").grid(row=0, column=0, sticky="w", padx=(0, 16)) self.tree = ttk.Treeview( self, columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"), show="headings", ) self.sort_column = "last_name" self.sort_descending = False self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL) ttk.Label(filters, text="Status").grid(row=0, column=1, sticky="w", padx=(0, 8)) self.status_filter = ttk.Combobox( filters, textvariable=self.status_filter_var, state="readonly", values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()], width=28, ) self.status_filter.grid(row=0, column=2, sticky="w") self.status_filter.bind("<>", lambda _event: self._render_members()) for key, title, width in MEMBER_TABLE_COLUMNS: self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column)) self.tree.column(key, width=width, anchor="w") self.tree.grid(row=2, column=0, sticky="nsew") self.tree.bind("", lambda _event: self._open_selected()) self.tree.bind("", lambda _event: self._open_selected()) self.refresh(self.members) def refresh(self, members: list[Member]) -> None: self.members = members self._render_members() def _render_members(self) -> None: self.tree.delete(*self.tree.get_children()) status_filter = _selected_status_filter(self.status_filter_var.get()) filtered_members = _filter_members(self.members, status_filter) sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending) if len(filtered_members) == len(self.members): self.count_var.set(f"{len(filtered_members)} Mitglieder") else: self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder") self._update_tree_headings() for member in sorted_members: self.tree.insert( "", "end", iid=member.member_id, values=( member.member_number, member.first_name, member.last_name, member.nickname, member.email, format_date_for_display(member.birth_date), MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), ), ) def _toggle_sort(self, column: str) -> None: if self.sort_column == column: self.sort_descending = not self.sort_descending else: self.sort_column = column self.sort_descending = False self._render_members() def _update_tree_headings(self) -> None: for key, title, _width in MEMBER_TABLE_COLUMNS: suffix = "" if key == self.sort_column: suffix = " v" if self.sort_descending else " ^" self.tree.heading(key, text=f"{title}{suffix}", command=lambda column=key: self._toggle_sort(column)) def _open_selected(self) -> None: selected = self.tree.selection() if selected: self.on_open(selected[0]) class HousekeeperTab(ttk.Frame): def __init__( self, master: tk.Misc, findings: list[HousekeeperFinding], on_open_member: Callable[[str], None], on_refresh: Callable[[], list[HousekeeperFinding]], on_delete: Callable[[str], list[HousekeeperFinding]], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.findings = findings self.on_open_member = on_open_member self.on_refresh = on_refresh self.on_delete = on_delete self.on_close = on_close self._build_ui() 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() ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label( header, text="Regeln prüfen Daten und führen idempotente Aktionen aus", style="Mono.TLabel", ).grid(row=1, column=0, sticky="w") ttk.Button(header, text="Neu prüfen", command=self.refresh).grid( row=0, column=1, rowspan=2, padx=(0, 8) ) ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2) self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings") for key, title, width in ( ("severity", "Level", 90), ("title", "Vorgang", 330), ("detail", "Details", 390), ("due", "Fällig", 110), ): self.tree.heading(key, text=title) self.tree.column(key, width=width, anchor="w") self.tree.grid(row=1, column=0, sticky="nsew") self.tree.bind("", lambda _event: self._open_selected()) self.tree.bind("<>", lambda _event: self._show_selected_details()) details = ttk.LabelFrame(self, text="Details", padding=12) details.grid(row=2, column=0, sticky="ew", pady=(10, 0)) details.columnconfigure(0, weight=1) self.detail_var = tk.StringVar(value="Eintrag auswählen, um Details anzuzeigen.") self.detail_label = ttk.Label( details, textvariable=self.detail_var, anchor="nw", justify="left", wraplength=800, style="Status.TLabel", ) self.detail_label.grid(row=0, column=0, sticky="ew") self.delete_button = ttk.Button( details, text="Task löschen", command=self._delete_selected, state="disabled", ) self.delete_button.grid(row=0, column=1, sticky="ne", padx=(12, 0)) details.bind( "", lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)), ) self._render() def refresh(self) -> None: self.findings = self.on_refresh() self._render() def _render(self) -> None: self.tree.delete(*self.tree.get_children()) self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") self.delete_button.configure(state="disabled") self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge") for index, finding in enumerate(self.findings): self.tree.insert( "", "end", iid=str(index), values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""), ) def _show_selected_details(self) -> None: selected = self.tree.selection() if not selected: self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.") self.delete_button.configure(state="disabled") return index = int(selected[0]) if index >= len(self.findings): return self.detail_var.set(_finding_details(self.findings[index])) self.delete_button.configure(state="normal" if self.findings[index].key else "disabled") def _delete_selected(self) -> None: selected = self.tree.selection() if not selected: return finding = self.findings[int(selected[0])] if not finding.key or not messagebox.askyesno( "Hausmeister-Task löschen", f"Task wirklich löschen?\n\n{finding.title}\n\n" "Falls die Ursache weiter besteht, wird er beim nächsten Lauf neu angelegt.", parent=self, ): return self.findings = self.on_delete(finding.key) self._render() def _open_selected(self) -> None: selected = self.tree.selection() if selected: self.on_open_member(self.findings[int(selected[0])].member_id) def _finding_details(finding: HousekeeperFinding) -> str: lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] if finding.key: lines.append(f"Key: {finding.key}") if finding.due_date: lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}") if finding.detail: lines.extend(("", finding.detail)) return "\n".join(lines)