feat: initialize CCMA member administration

This commit is contained in:
Marcel Peterkau
2026-06-21 16:46:15 +02:00
parent 4c6a1191ee
commit dfd5b1192b
184 changed files with 5051 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
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 HousekeeperFinding, Member
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository
from ccma.ui.dialogs import NewMemberDialog
from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab
from ccma.ui.options_dialog import OptionsDialog
from ccma.ui.theme import load_theme
from ccma.ui.work_tabs import 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("<Return>", lambda _event: self.search())
entry.bind("<Control-l>", 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))
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
member = self.repository.get_member(member_id)
tab = MemberTab(
self.notebook,
self.repository,
member_id,
on_close=lambda: self.tabs.close(key),
on_changed=self.refresh_overview,
)
self.tabs.add(
key,
tab,
member.display_name or member.member_number,
image=self.icons.get("account", 16),
icon_name="account",
)
def open_members(self) -> None:
key = "members"
if self.tabs.focus(key):
return
tab = MembersTab(
self.notebook,
self.repository.list_members(),
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 new_member(self) -> None:
NewMemberDialog(self, self.repository, self._member_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 open_housekeeper(self) -> None:
key = "housekeeper"
if self.tabs.focus(key):
return
tab = HousekeeperTab(
self.notebook,
self.findings,
self.open_member,
self.run_housekeeper,
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 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())
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:
messagebox.showwarning(
"Datenprüfung",
"Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]),
parent=self,
)