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 ( ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, HousekeeperFinding, Member, ) from ccma.ui.labels import storage_key from ccma.ui.sections import titled_frame 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" ASSET_FILTER_ALL = "Alle" ASSET_TABLE_COLUMNS = ( ("label", "Bezeichnung", 260), ("category", "Kategorie", 140), ("inventory_number", "Inventarnummer", 140), ("status", "Status", 140), ("holder", "Mitglied", 240), ) 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) def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame: return titled_frame(parent, "// FILTER") def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str: if column == "label": return asset.label if column == "category": return asset.category if column == "inventory_number": return asset.inventory_number if column == "status": return ASSET_STATUS_LABELS.get(asset.status, asset.status) if column == "holder": return holder_label return "" def _filter_assets(assets: list[Asset], status_filter: str) -> list[Asset]: if status_filter == "all": return list(assets) return [asset for asset in assets if asset.status == status_filter] 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 = _filter_label_frame(self) filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) filter_row = ttk.Frame(filters) filter_row.grid(row=0, column=0, sticky="w") 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(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) self.status_filter = ttk.Combobox( filter_row, textvariable=self.status_filter_var, state="readonly", values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()], width=28, ) self.status_filter.grid(row=0, column=1, 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 AssetsTab(ttk.Frame): def __init__( self, master: tk.Misc, assets: list[Asset], resolve_holder_label: Callable[[str], str], on_new: Callable[[], None], on_open: Callable[[str], None], on_edit: Callable[[str], None], on_issue: Callable[[str], None], on_return: Callable[[str], None], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.assets = assets self.resolve_holder_label = resolve_holder_label self.on_new = on_new self.on_open = on_open self.on_edit = on_edit self.on_issue = on_issue self.on_return = on_return self.on_close = on_close self.sort_column = "label" self.sort_descending = False self.status_filter_var = tk.StringVar(value=ASSET_FILTER_ALL) 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="INVENTAR", 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") button_row = ttk.Frame(header) button_row.grid(row=0, column=1, rowspan=2, sticky="e") ttk.Button(button_row, text="Neues Asset", command=self.on_new).pack(side="left", padx=(0, 8)) ttk.Button(button_row, text="Tab schließen", command=self.on_close).pack(side="left") filters = _filter_label_frame(self) filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) filter_row = ttk.Frame(filters) filter_row.grid(row=0, column=0, sticky="w") ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) self.status_filter = ttk.Combobox( filter_row, textvariable=self.status_filter_var, state="readonly", values=[ASSET_FILTER_ALL, *ASSET_STATUS_LABELS.values()], width=22, ) self.status_filter.grid(row=0, column=1, sticky="w") self.status_filter.bind("<>", lambda _event: self._render_assets()) self.tree = ttk.Treeview( self, columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS), show="headings", ) for key, title, width in ASSET_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("", self._open_selected) self.tree.bind("<>", lambda _event: self._update_actions()) actions = ttk.Frame(self) actions.grid(row=3, column=0, sticky="e", pady=(10, 0)) self.edit_button = ttk.Button( actions, text="Bearbeiten", command=self._edit_selected, state="disabled", ) self.edit_button.pack(side="left", padx=(0, 8)) self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled") self.open_button.pack(side="left", padx=(0, 8)) self.issue_button = ttk.Button( actions, text="Ausgeben", command=self._issue_selected, state="disabled", ) self.issue_button.pack(side="left", padx=(0, 8)) self.return_button = ttk.Button( actions, text="Zurücknehmen", command=self._return_selected, state="disabled", ) self.return_button.pack(side="left") self.refresh(self.assets) def refresh(self, assets: list[Asset]) -> None: self.assets = assets self._render_assets() def _render_assets(self) -> None: self.tree.delete(*self.tree.get_children()) status_filter = _selected_asset_filter(self.status_filter_var.get()) filtered_assets = _filter_assets(self.assets, status_filter) sorted_assets = sorted( filtered_assets, key=lambda asset: _asset_table_value( asset, self.sort_column, self.resolve_holder_label(asset.current_holder_member_id), ).casefold(), reverse=self.sort_descending, ) if len(filtered_assets) == len(self.assets): self.count_var.set(f"{len(filtered_assets)} Assets") else: self.count_var.set(f"{len(filtered_assets)} / {len(self.assets)} Assets") self._update_tree_headings() for asset in sorted_assets: self.tree.insert( "", "end", iid=asset.asset_id, values=( asset.label, asset.category, asset.inventory_number, ASSET_STATUS_LABELS.get(asset.status, asset.status), self.resolve_holder_label(asset.current_holder_member_id), ), ) self._update_actions() 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_assets() def _update_tree_headings(self) -> None: for key, title, _width in ASSET_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 _selected_asset_id(self) -> str: selected = self.tree.selection() return selected[0] if selected else "" def _selected_asset(self) -> Asset | None: selected_asset_id = self._selected_asset_id() return next((asset for asset in self.assets if asset.asset_id == selected_asset_id), None) def _update_actions(self) -> None: asset = self._selected_asset() if asset is None: self.edit_button.configure(state="disabled") self.open_button.configure(state="disabled") self.issue_button.configure(state="disabled") self.return_button.configure(state="disabled") return self.edit_button.configure(state="normal") self.open_button.configure(state="normal") self.issue_button.configure(state="normal" if asset.status == "available" else "disabled") self.return_button.configure(state="normal" if asset.current_holder_member_id else "disabled") def _open_selected(self, _event: tk.Event | None = None) -> None: asset_id = self._selected_asset_id() if not asset_id: asset_id = self.tree.focus() if asset_id: self.on_open(asset_id) def _edit_selected(self, _event: tk.Event | None = None) -> None: asset_id = self._selected_asset_id() if not asset_id: asset_id = self.tree.focus() if asset_id: self.on_edit(asset_id) def _issue_selected(self, _event: tk.Event | None = None) -> None: asset_id = self._selected_asset_id() if not asset_id: asset_id = self.tree.focus() if asset_id: self.on_issue(asset_id) def _return_selected(self) -> None: asset_id = self._selected_asset_id() if asset_id: self.on_return(asset_id) class HousekeeperTab(ttk.Frame): def __init__( self, master: tk.Misc, findings: list[HousekeeperFinding], on_open_target: Callable[[HousekeeperFinding], 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_target = on_open_target 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_target(self.findings[int(selected[0])]) def _finding_details(finding: HousekeeperFinding) -> str: lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] if finding.target_type == "asset" and finding.asset_id: lines.append(f"Asset: {finding.asset_id}") elif finding.member_id: lines.append(f"Mitglied: {finding.member_id}") 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) def _selected_asset_filter(label: str) -> str: if label == ASSET_FILTER_ALL: return "all" return storage_key(ASSET_STATUS_LABELS, label)