mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
feat: initialize CCMA member administration
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user