from __future__ import annotations import tkinter as tk from tkinter import messagebox, ttk from ccma import __version__ from ccma.config import AppConfig from ccma.domain.models import Asset, HousekeeperFinding, Member from ccma.services.housekeeper import Housekeeper from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.asset_tab import AssetTab from ccma.ui.claim_tab import ClaimTab from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog from ccma.ui.icons import IconStore from ccma.ui.member_tab import MemberTab from ccma.ui.options_dialog import OptionsDialog from ccma.ui.sepa_dialog import SepaExportDialog from ccma.ui.theme import load_theme from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab class TabManager: def __init__(self, notebook: ttk.Notebook): self.notebook = notebook self.tabs: dict[str, tk.Widget] = {} self.images: dict[str, tk.PhotoImage] = {} self.icon_names: dict[str, str] = {} def add( self, key: str, widget: tk.Widget, title: str, *, select: bool = True, image: tk.PhotoImage | None = None, icon_name: str | None = None, ) -> None: self.tabs[key] = widget options: dict[str, object] = {"text": title, "padding": (6, 3)} if image: options.update(image=image, compound="left") self.images[key] = image if icon_name: self.icon_names[key] = icon_name self.notebook.add(widget, **options) if select: self.notebook.select(widget) def focus(self, key: str) -> bool: widget = self.tabs.get(key) if widget and widget.winfo_exists(): self.notebook.select(widget) return True self.tabs.pop(key, None) return False def close(self, key: str) -> None: widget = self.tabs.pop(key, None) self.images.pop(key, None) self.icon_names.pop(key, None) if widget and widget.winfo_exists(): self.notebook.forget(widget) widget.destroy() def refresh_icons(self, icons: IconStore) -> None: for key, icon_name in self.icon_names.items(): widget = self.tabs.get(key) image = icons.get(icon_name, 16) if widget and widget.winfo_exists() and image: self.images[key] = image self.notebook.tab(widget, image=image, compound="left") class MainWindow(ttk.Frame): def __init__( self, master: tk.Tk, repository: MemberRepository, config: AppConfig, findings: list[HousekeeperFinding], validation_errors: list[str], ): super().__init__(master, padding=(12, 8)) self.repository = repository self.config = config self.findings = findings self.validation_errors = validation_errors self.search_var = tk.StringVar() self.status_var = tk.StringVar(value="Bereit.") self.search_counter = 1 self.icons = IconStore(self) self.ribbon: ttk.Frame | None = None self.housekeeper_button: ttk.Button | None = None self._build_ui() if validation_errors: self.after_idle(self._show_validation_warning) def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) self._build_ribbon() self.notebook = ttk.Notebook(self) self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0)) self.tabs = TabManager(self.notebook) self.dashboard = DashboardTab( self.notebook, self.repository.member_count(), self.findings, self.open_housekeeper, ) self.tabs.add( "dashboard", self.dashboard, "Dashboard", image=self.icons.get("view-dashboard", 16), icon_name="view-dashboard", ) status = ttk.Frame(self, padding=(6, 5)) status.grid(row=2, column=0, sticky="ew") status.columnconfigure(0, weight=1) ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label( status, text=f"STORE {self.repository.root} · VERSION {__version__}", style="Status.TLabel", ).grid(row=0, column=1, sticky="e") def _build_ribbon(self) -> None: ribbon = ttk.Frame(self, style="Ribbon.TFrame") self.ribbon = ribbon ribbon.grid(row=0, column=0, sticky="ew") ribbon.columnconfigure(2, weight=1) title = ttk.Frame(ribbon) title.grid(row=0, column=0, sticky="w", padx=(0, 18)) ttk.Label(title, text="CCMA", style="AppTitle.TLabel").pack(anchor="w") ttk.Label(title, text="// MEMBER ADMIN", style="Mono.TLabel").pack(anchor="w") search = ttk.Frame(ribbon) search.grid(row=0, column=1, sticky="w") entry = ttk.Entry(search, textvariable=self.search_var, font=("TkDefaultFont", 11), width=40) entry.grid(row=0, column=0, sticky="w") entry.bind("", lambda _event: self.search()) entry.bind("", lambda _event: entry.focus_set()) ttk.Label(search, text="Name · E-Mail · Geburtsdatum · Mitgliedsnummer", style="Mono.TLabel").grid( row=1, column=0, sticky="w", pady=(3, 0) ) search_icon = self.icons.get("magnify", 24) search_button = ttk.Button( search, text="Suchen", image=search_icon, compound="top", width=14, command=self.search, ) search_button.image = search_icon search_button.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(6, 0)) ttk.Separator(ribbon, orient="vertical").grid( row=0, column=3, sticky="ns", padx=16, ) actions = ttk.Frame(ribbon) actions.grid(row=0, column=4, sticky="e") members_icon = self.icons.get("account-group", 24) or self.icons.get("account-multiple", 24) members_button = ttk.Button( actions, text="Mitglieder", image=members_icon, compound="top", width=14, command=self.open_members, ) members_button.image = members_icon members_button.pack(side="left", padx=(0, 6)) assets_icon = self.icons.get("key-variant", 24) or self.icons.get("package-variant-closed", 24) assets_button = ttk.Button( actions, text="Inventar", image=assets_icon, compound="top", width=14, command=self.open_assets, ) assets_button.image = assets_icon assets_button.pack(side="left", padx=(0, 6)) sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24) sepa_button = ttk.Button( actions, text="SEPA", image=sepa_icon, compound="top", width=14, command=self.open_sepa_export, ) sepa_button.image = sepa_icon sepa_button.pack(side="left", padx=(0, 6)) new_icon = self.icons.get("account-plus", 24) new_button = ttk.Button( actions, text="Neues Mitglied", image=new_icon, compound="top", width=14, command=self.new_member, ) new_button.image = new_icon new_button.pack(side="left", padx=(0, 6)) housekeeper_icon = self.icons.get("broom", 24) or self.icons.get("clipboard-check", 24) self.housekeeper_button = ttk.Button( actions, text=f"Hausmeister ({len(self.findings)})", image=housekeeper_icon, compound="top", width=14, style="Accent.TButton", command=self.open_housekeeper, ) self.housekeeper_button.image = housekeeper_icon self.housekeeper_button.pack(side="left", padx=(0, 6)) theme_icon = self.icons.get("theme-light-dark", 24) theme_button = ttk.Button( actions, text="Light/Dark", image=theme_icon, compound="top", width=14, command=self.toggle_theme, ) theme_button.image = theme_icon theme_button.pack(side="left", padx=(0, 6)) options_icon = self.icons.get("cog", 24) options_button = ttk.Button( actions, text="Optionen", image=options_icon, compound="top", width=14, command=self.open_options, ) options_button.image = options_icon options_button.pack(side="left", padx=(0, 6)) exit_icon = self.icons.get("logout", 24) or self.icons.get("exit-to-app", 24) exit_button = ttk.Button( actions, text="Beenden", image=exit_icon, compound="top", width=14, command=getattr(self.master, "close", self.master.destroy), ) exit_button.image = exit_icon exit_button.pack(side="left") def search(self) -> None: query = self.search_var.get().strip() if not query: self.status_var.set("Bitte einen Suchbegriff eingeben.") return results = self.repository.search(query) self.search_var.set("") if len(results) == 1: self.open_member(results[0].member_id) self.status_var.set(f"Eindeutiger Treffer: {results[0].display_name}") return key = f"search:{self.search_counter}" self.search_counter += 1 tab = SearchResultsTab( self.notebook, query, results, self.open_member, lambda: self.tabs.close(key), ) self.tabs.add( key, tab, f"Suche: {query[:20]}", image=self.icons.get("format-list-text", 16), icon_name="format-list-text", ) self.status_var.set(f"{len(results)} Treffer für {query}") def open_member(self, member_id: str) -> None: key = f"member:{member_id}" if self.tabs.focus(key): return try: member = self.repository.get_member(member_id) except RepositoryError as exc: self.status_var.set(str(exc)) messagebox.showerror("Mitgliederakte beschädigt", str(exc), parent=self) return tab = MemberTab( self.notebook, self.repository, member_id, on_close=lambda: self.tabs.close(key), on_changed=self.refresh_overview, on_open_claim=self.open_claim, on_open_assets=self.open_assets, on_open_asset=self.open_asset, on_return_asset=self.return_asset, ) self.tabs.add( key, tab, member.display_name or member.member_number, image=self.icons.get("account", 16), icon_name="account", ) def open_claim(self, member_id: str, claim_id: str) -> None: key = f"claim:{member_id}:{claim_id}" if self.tabs.focus(key): return try: _data, claim = self.repository.get_claim(member_id, claim_id) except RepositoryError as exc: messagebox.showerror("Forderung konnte nicht geöffnet werden", str(exc), parent=self) return tab = ClaimTab( self.notebook, self.repository, member_id, claim_id, on_close=lambda: self.tabs.close(key), on_changed=lambda: self._claim_changed(member_id), ) self.tabs.add( key, tab, str(claim.get("title") or "Forderung"), image=self.icons.get("receipt", 16) or self.icons.get("file-document", 16), icon_name="receipt", ) def open_asset(self, asset_id: str) -> None: key = f"asset:{asset_id}" if self.tabs.focus(key): return try: asset = self.repository.get_asset(asset_id) except RepositoryError as exc: messagebox.showerror("Asset konnte nicht geöffnet werden", str(exc), parent=self) return tab = AssetTab( self.notebook, self.repository, asset_id, on_close=lambda: self.tabs.close(key), on_changed=self.refresh_overview, on_open_member=self.open_member, on_issue_asset=self.issue_asset, on_return_asset=self.return_asset, on_open_claim=self.open_claim, ) self.tabs.add( key, tab, asset.label or asset.asset_id, image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16), icon_name="key-variant", ) def _claim_changed(self, member_id: str) -> None: member_tab = self.tabs.tabs.get(f"member:{member_id}") if isinstance(member_tab, MemberTab) and member_tab.winfo_exists(): member_tab.refresh() self.refresh_overview() def open_members(self) -> None: key = "members" if self.tabs.focus(key): return tab = MembersTab( self.notebook, self.repository.list_members(), self.findings, self.open_member, lambda: self.tabs.close(key), ) self.tabs.add( key, tab, "Mitglieder", image=self.icons.get("account-group", 16) or self.icons.get("account-multiple", 16), icon_name="account-group", ) def open_assets(self) -> None: key = "assets" if self.tabs.focus(key): return tab = AssetsTab( self.notebook, self.repository.list_assets(), self._member_reference_label, self.new_asset, self.open_asset, self.edit_asset, self.issue_asset, self.return_asset, lambda: self.tabs.close(key), ) self.tabs.add( key, tab, "Inventar", image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16), icon_name="key-variant", ) def open_sepa_export(self) -> None: SepaExportDialog(self, self.repository) def new_member(self) -> None: NewMemberDialog(self, self.repository, self._member_created) def new_asset(self) -> None: NewAssetDialog(self, self.repository, self._asset_created) def _member_created(self, member: Member) -> None: self.refresh_overview() self.open_member(member.member_id) self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.") def _asset_created(self, asset: Asset) -> None: self.refresh_overview() self.open_assets() self.status_var.set(f"Asset angelegt: {asset.label}") def issue_asset(self, asset_id: str, preselected_member_id: str = "") -> None: try: IssueAssetDialog( self, self.repository, asset_id, self._asset_changed, preselected_member_id=preselected_member_id, ) except (RepositoryError, tk.TclError) as exc: messagebox.showerror("Asset konnte nicht ausgegeben werden", str(exc), parent=self) def edit_asset(self, asset_id: str) -> None: try: EditAssetDialog( self, self.repository, asset_id, self._asset_changed, ) except (RepositoryError, tk.TclError) as exc: messagebox.showerror("Asset konnte nicht bearbeitet werden", str(exc), parent=self) def return_asset(self, asset_id: str) -> None: try: asset = self.repository.get_asset(asset_id) except RepositoryError as exc: messagebox.showerror("Asset konnte nicht geladen werden", str(exc), parent=self) return if not messagebox.askyesno( "Asset zurücknehmen", f"{asset.label}\n\nDieses Asset wirklich als zurückgegeben markieren?", parent=self, ): return try: asset = self.repository.return_asset(asset_id) except RepositoryError as exc: messagebox.showerror("Rückgabe fehlgeschlagen", str(exc), parent=self) return self._asset_changed(asset) def _asset_changed(self, asset: Asset) -> None: self.refresh_overview() self.status_var.set(f"Asset aktualisiert: {asset.label}") def open_housekeeper(self) -> None: key = "housekeeper" if self.tabs.focus(key): return tab = HousekeeperTab( self.notebook, self.findings, self._open_housekeeper_target, self.run_housekeeper, self.delete_housekeeper_task, lambda: self.tabs.close(key), ) self.tabs.add( key, tab, "Hausmeister", image=self.icons.get("broom", 16), icon_name="broom", ) def run_housekeeper(self) -> list[HousekeeperFinding]: self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() self.refresh_overview(run_housekeeper=False) self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.") return self.findings def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]: self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key) self.refresh_overview(run_housekeeper=False) self.status_var.set("Hausmeister-Task gelöscht.") return self.findings def refresh_overview(self, *, run_housekeeper: bool = True) -> None: if run_housekeeper: self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run() self.dashboard.update_data(self.repository.member_count(), self.findings) members_tab = self.tabs.tabs.get("members") if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): members_tab.refresh(self.repository.list_members(), self.findings) assets_tab = self.tabs.tabs.get("assets") if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists(): assets_tab.refresh(self.repository.list_assets()) for key, widget in self.tabs.tabs.items(): if key.startswith("member:") and isinstance(widget, MemberTab) and widget.winfo_exists(): widget.refresh() if key.startswith("asset:") and isinstance(widget, AssetTab) and widget.winfo_exists(): widget.refresh() if self.housekeeper_button and self.housekeeper_button.winfo_exists(): self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})") def toggle_theme(self) -> None: self.config.theme_mode = "light" if self.config.theme_mode == "dark" else "dark" self.config.save() load_theme(self.master, self.config.theme_mode) self._refresh_ribbon_icons() self.status_var.set(f"Theme: {self.config.theme_mode}.") def open_options(self) -> None: OptionsDialog(self, self.config, self.repository, self._options_saved) def _options_saved(self, store_changed: bool) -> None: load_theme(self.master, self.config.theme_mode) self._refresh_ribbon_icons() if store_changed: self.status_var.set("Mitglieder-Store geändert. Die Änderung wird beim Neustart aktiv.") messagebox.showinfo( "Neustart erforderlich", "Der neue Mitglieder-Store wird beim nächsten Programmstart geöffnet.", parent=self, ) else: self.status_var.set("Optionen gespeichert.") def _refresh_ribbon_icons(self) -> None: if self.ribbon and self.ribbon.winfo_exists(): self.ribbon.destroy() self.icons = IconStore(self) self._build_ribbon() self.tabs.refresh_icons(self.icons) def _show_validation_warning(self) -> None: display_errors = [ item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item ] if not display_errors: return messagebox.showwarning( "Datenprüfung", "Der Store enthält ungültige Akten:\n\n" + "\n".join(display_errors[:12]), parent=self, ) def _open_housekeeper_target(self, finding: HousekeeperFinding) -> None: if finding.target_type == "asset" and finding.asset_id: self.open_asset(finding.asset_id) return if finding.member_id: self.open_member(finding.member_id) def _member_reference_label(self, member_id: str) -> str: if not member_id: return "—" try: member = self.repository.get_member(member_id) except RepositoryError: return member_id prefix = member.member_number or member.member_id return f"{prefix} · {member.display_name or member.member_id}"