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
+1
View File
@@ -0,0 +1 @@
"""Tk user interface for CCMA."""
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import json
import tkinter as tk
from pathlib import Path
from tkinter import ttk
from typing import Any
from ccma import __version__
from ccma.ui.icons import IconStore
CHANGELOG_PATH = Path(__file__).resolve().parent.parent / "assets" / "CHANGELOG.json"
def load_changelog(path: Path = CHANGELOG_PATH) -> list[dict[str, Any]]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return []
if not isinstance(data, list):
return []
return [entry for entry in data if isinstance(entry, dict)]
class ChangelogView(ttk.Frame):
def __init__(self, master: tk.Misc):
super().__init__(master, padding=14)
self.icons = IconStore(self)
self.entries = load_changelog()
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, 14))
header.columnconfigure(1, weight=1)
history_icon = self.icons.get("history", 28)
icon_label = ttk.Label(header, image=history_icon)
icon_label.image = history_icon
icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10))
ttk.Label(header, text="Änderungsverlauf", style="TabTitle.TLabel").grid(row=0, column=1, sticky="w")
ttk.Label(
header,
text=f"Aktuelle Version {__version__} · {len(self.entries)} Release-Einträge",
style="Mono.TLabel",
).grid(row=1, column=1, sticky="w", pady=(2, 0))
container = ttk.Frame(self)
container.grid(row=1, column=0, sticky="nsew")
container.columnconfigure(0, weight=1)
container.rowconfigure(0, weight=1)
background = ttk.Style(self).lookup("TFrame", "background") or "#ffffff"
self.canvas = tk.Canvas(container, highlightthickness=0, borderwidth=0, background=background)
scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview)
self.canvas.configure(yscrollcommand=scrollbar.set)
self.canvas.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
self.content = ttk.Frame(self.canvas)
self.content.columnconfigure(0, weight=1)
self.content_window = self.canvas.create_window((0, 0), anchor="nw", window=self.content)
self.content.bind("<Configure>", self._update_scroll_region)
self.canvas.bind("<Configure>", self._resize_content)
self._render_entries()
self._bind_mousewheel_tree(self.canvas)
self._bind_mousewheel_tree(self.content)
def _render_entries(self) -> None:
if not self.entries:
ttk.Label(self.content, text="Kein Changelog verfügbar.", style="Muted.TLabel").grid(
row=0, column=0, sticky="w", padx=8, pady=8
)
return
for row, entry in enumerate(self.entries):
version = str(entry.get("version", "unbekannt"))
card = ttk.LabelFrame(self.content, padding=14)
card.grid(row=row, column=0, sticky="ew", padx=(0, 8), pady=(0, 12))
card.columnconfigure(1, weight=1)
ttk.Label(card, text=f"VERSION {version}", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w"
)
if version == __version__:
ttk.Label(card, text="CURRENT", style="Mono.TLabel").grid(
row=0, column=1, sticky="w", padx=(12, 0)
)
ttk.Label(card, text=str(entry.get("date", "")), style="Muted.TLabel").grid(
row=0, column=2, sticky="e"
)
changes = entry.get("changes", [])
if not isinstance(changes, list):
continue
for change_row, change in enumerate(changes, start=1):
ttk.Label(card, text="", style="TimelineHeader.TLabel").grid(
row=change_row, column=0, sticky="nw", pady=(7, 0)
)
ttk.Label(
card,
text=str(change),
justify="left",
wraplength=650,
).grid(row=change_row, column=1, columnspan=2, sticky="w", padx=(8, 0), pady=(7, 0))
def _update_scroll_region(self, _event: tk.Event | None = None) -> None:
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def _resize_content(self, event: tk.Event) -> None:
self.canvas.itemconfigure(self.content_window, width=event.width)
def _bind_mousewheel_tree(self, widget: tk.Misc) -> None:
widget.bind("<MouseWheel>", self._on_mousewheel, add="+")
widget.bind("<Button-4>", self._on_mousewheel, add="+")
widget.bind("<Button-5>", self._on_mousewheel, add="+")
for child in widget.winfo_children():
self._bind_mousewheel_tree(child)
def _on_mousewheel(self, event: tk.Event) -> None:
if getattr(event, "num", None) == 4:
delta = -1
elif getattr(event, "num", None) == 5:
delta = 1
else:
delta = -1 if getattr(event, "delta", 0) > 0 else 1
self.canvas.yview_scroll(delta * 3, "units")
+90
View File
@@ -0,0 +1,90 @@
import tkinter as tk
from collections.abc import Callable
from tkinter import messagebox, ttk
from ccma.domain.dates import age_label, date_input_hint
from ccma.domain.models import Member
from ccma.storage.repository import MemberRepository, RepositoryError
class NewMemberDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]):
super().__init__(master)
self.repository = repository
self.on_created = on_created
self.title("Neue Mitgliederakte")
self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(False, False)
self.number_policy = repository.get_member_number_policy()
self.variables = {
name: tk.StringVar()
for name in ("first_name", "last_name", "email", "birth_date", "member_number")
}
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._create())
self.after_idle(self._focus_first)
def _build_ui(self) -> None:
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
fields = [
("Vorname *", "first_name"),
("Nachname *", "last_name"),
("E-Mail-Adresse", "email"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
]
if self.number_policy["mode"] == "manual":
fields.append(("Mitgliedsnummer *", "member_number"))
self.entries: dict[str, ttk.Entry] = {}
for row, (label, key) in enumerate(fields):
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date":
birth_row = ttk.Frame(frame)
birth_row.grid(row=row, column=1, sticky="ew", pady=5)
birth_row.columnconfigure(0, weight=1)
entry = ttk.Entry(birth_row, textvariable=self.variables[key], width=24)
entry.grid(row=0, column=0, sticky="ew")
self.birth_age_var = tk.StringVar(value="Alter: —")
ttk.Label(birth_row, textvariable=self.birth_age_var, style="Mono.TLabel").grid(
row=0, column=1, sticky="w", padx=(10, 0)
)
self.variables[key].trace_add(
"write",
lambda *_args: self.birth_age_var.set(age_label(self.variables["birth_date"].get())),
)
else:
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38)
entry.grid(row=row, column=1, sticky="ew", pady=5)
self.entries[key] = entry
button_row = len(fields)
if self.number_policy["mode"] == "automatic":
preview = self.repository.preview_member_number(self.number_policy["pattern"])
ttk.Label(frame, text="Mitgliedsnummer").grid(
row=button_row, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Label(frame, text=f"Automatisch: {preview}", style="TimelineHeader.TLabel").grid(
row=button_row, column=1, sticky="w", pady=5
)
button_row += 1
buttons = ttk.Frame(frame)
buttons.grid(row=button_row, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Akte anlegen", style="Accent.TButton", command=self._create).pack(
side="left"
)
def _focus_first(self) -> None:
self.entries["first_name"].focus_set()
def _create(self) -> None:
try:
member = self.repository.create_member(
**{key: variable.get() for key, variable in self.variables.items()}
)
except RepositoryError as exc:
messagebox.showerror("Akte konnte nicht angelegt werden", str(exc), parent=self)
return
self.destroy()
self.on_created(member)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import tkinter as tk
from importlib import import_module, resources
from pathlib import Path
from tkinter import ttk
class IconStore:
"""Load and cache Material Design icons used by ttk widgets."""
def __init__(self, master: tk.Misc):
self.master = master
self.style = ttk.Style(master)
self._cache: dict[tuple[str, int, str], tk.PhotoImage | None] = {}
self._mat_icon = self._load_mat_icon_class()
def get(self, name: str, size: int = 16, color: str | None = None) -> tk.PhotoImage | None:
resolved_color = color or self._theme_color()
key = (name, size, resolved_color)
if key not in self._cache:
self._cache[key] = self._load(name, size, resolved_color)
return self._cache[key]
def clear(self) -> None:
self._cache.clear()
def _load(self, name: str, size: int, color: str) -> tk.PhotoImage | None:
if self._mat_icon:
try:
return self._mat_icon(name, size=size, color=color).image
except Exception:
pass
return self._load_asset_fallback(name, size)
def _load_asset_fallback(self, name: str, size: int) -> tk.PhotoImage | None:
for module_name in ("ttkbootstrap_icons_mat", "ttkbootstrap_icons"):
try:
root = resources.files(import_module(module_name))
except (ImportError, TypeError):
continue
candidates = {name, name.replace("-", "_"), name.replace("_", "-")}
for candidate in candidates:
for extension in (".png", ".gif"):
try:
path = Path(next(root.rglob(f"{candidate}{extension}")))
image = tk.PhotoImage(master=self.master, file=str(path))
if size and image.width() > size:
factor = max(1, round(image.width() / size))
image = image.subsample(factor, factor)
return image
except (StopIteration, tk.TclError, OSError):
continue
return None
@staticmethod
def _load_mat_icon_class():
try:
module = import_module("ttkbootstrap_icons_mat")
except ImportError:
return None
return getattr(module, "MatIcon", None)
def _theme_color(self) -> str:
try:
theme_name = self.style.theme_use().lower()
except tk.TclError:
theme_name = ""
if "dark" in theme_name:
return "#eeeeee"
if "light" in theme_name:
return "#313131"
background = self.style.lookup("TFrame", "background") or "#ffffff"
try:
red, green, blue = self.master.winfo_rgb(background)
luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 65535
return "#eeeeee" if luminance < 0.5 else "#313131"
except tk.TclError:
return "#313131"
+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,
)
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
import subprocess
import sys
import tkinter as tk
from collections.abc import Callable
from datetime import datetime
from tkinter import messagebox, ttk
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError
class MemberTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_close: Callable[[], None],
on_changed: Callable[[], None],
):
super().__init__(master, padding=12)
self.repository = repository
self.member_id = member_id
self.on_close = on_close
self.on_changed = on_changed
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._build_ui()
self.refresh()
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()
self.status_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(
row=0, column=1, rowspan=2, sticky="e"
)
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=1, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
self.pane.add(self.timeline_pane, weight=3)
self._build_details(self.details_pane)
self._build_timeline(self.timeline_pane)
self._pane_position_initialized = False
self.pane.bind("<Configure>", self._set_initial_pane_position, add="+")
def _set_initial_pane_position(self, event: tk.Event | None = None) -> None:
if self._pane_position_initialized:
return
try:
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
if width > 1:
self.pane.sashpos(0, max(360, int(width * 0.4)))
self._pane_position_initialized = True
except tk.TclError:
return
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16)
contribution_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(contribution_tab, text="Beiträge")
notebook.add(documents_tab, text="Dokumente")
fields = [
("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"),
("Nachname", "last_name"),
("E-Mail-Adresse", "email"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
]
for row, (label, key) in enumerate(fields):
variable = tk.StringVar()
self.variables[key] = variable
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date":
birth_row = ttk.Frame(data_tab)
birth_row.grid(row=row, column=1, sticky="ew", pady=5)
birth_row.columnconfigure(0, weight=1)
ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew")
self.age_var = tk.StringVar(value="Alter: —")
ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid(
row=0, column=1, sticky="w", padx=(10, 0)
)
variable.trace_add(
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
)
else:
ttk.Entry(data_tab, textvariable=variable, width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self.variables["status"] = tk.StringVar()
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
data_tab,
textvariable=self.variables["status"],
values=list(STATUS_LABELS),
state="readonly",
width=39,
).grid(row=len(fields), column=1, sticky="ew", pady=5)
self.variables["notes"] = tk.StringVar()
ttk.Label(data_tab, text="Interne Notiz").grid(
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
)
ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid(
row=len(fields) + 1, column=1, sticky="ew", pady=5
)
data_tab.columnconfigure(1, weight=1)
ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid(
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
)
contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar()
ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.claims = ttk.Treeview(
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
)
for key, title, width in (
("title", "Forderung", 220),
("due", "Fällig", 100),
("amount", "Betrag", 90),
("status", "Status", 110),
):
self.claims.heading(key, text=title)
self.claims.column(key, width=width, anchor="w")
self.claims.grid(row=1, column=0, sticky="nsew")
documents_tab.columnconfigure(0, weight=1)
documents_tab.rowconfigure(1, weight=1)
ttk.Button(documents_tab, text="Dateiordner öffnen", command=self._open_files).grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.documents = tk.Listbox(documents_tab, borderwidth=0, highlightthickness=0)
self.documents.grid(row=1, column=0, sticky="nsew")
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
self.timeline = ttk.Treeview(
parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview"
)
self.timeline.heading("time", text="Zeit")
self.timeline.heading("summary", text="Ereignis")
self.timeline.column("time", width=135, stretch=False)
self.timeline.column("summary", width=320, stretch=True)
self.timeline.grid(row=1, column=0, sticky="nsew")
compose = ttk.Frame(parent)
compose.grid(row=2, column=0, sticky="ew", pady=(10, 0))
compose.columnconfigure(0, weight=1)
self.comment_var = tk.StringVar()
comment = ttk.Entry(compose, textvariable=self.comment_var)
comment.grid(row=0, column=0, sticky="ew", padx=(0, 6))
comment.bind("<Return>", lambda _event: self._add_comment())
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None:
self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}")
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
date_fields = {"birth_date", "accepted_at", "membership_started_at"}
for key, variable in self.variables.items():
value = getattr(self.member, key)
variable.set(format_date_for_display(value) if key in date_fields else value)
self._refresh_events()
self._refresh_contributions()
self._refresh_documents()
def _refresh_events(self) -> None:
self.timeline.delete(*self.timeline.get_children())
try:
events = self.repository.get_events(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Chronik beschädigt", str(exc), parent=self)
return
for event in reversed(events):
self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))
def _refresh_contributions(self) -> None:
data = self.repository.get_contributions(self.member_id)
self.claims.delete(*self.claims.get_children())
for claim in data.claims:
self.claims.insert(
"",
"end",
values=(
claim.get("title", "Beitrag"),
claim.get("due_date", ""),
claim.get("amount", ""),
claim.get("status", "open"),
),
)
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
def _refresh_documents(self) -> None:
self.documents.delete(0, "end")
root = self.repository.members_root / self.member_id / "files"
for path in sorted(root.rglob("*")):
if path.is_file():
self.documents.insert("end", str(path.relative_to(root)))
def _save(self) -> None:
for key, variable in self.variables.items():
setattr(self.member, key, variable.get().strip())
try:
self.repository.save_member(self.member)
except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text:
return
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
self.comment_var.set("")
self._refresh_events()
def _open_files(self) -> None:
path = self.repository.members_root / self.member_id / "files"
if sys.platform == "win32":
subprocess.Popen(["explorer", str(path)])
elif sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
else:
subprocess.Popen(["xdg-open", str(path)])
def _format_timestamp(event: Event) -> str:
try:
return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M")
except ValueError:
return event.timestamp[:16]
def _event_label(event: Event) -> str:
if event.actor_type == "system":
return f"[AUTO] {event.summary}"
return event.summary
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import re
import tkinter as tk
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class MonitorBounds:
x: int
y: int
width: int
height: int
primary: bool = False
@property
def center(self) -> tuple[int, int]:
return self.x + self.width // 2, self.y + self.height // 2
def as_tuple(self) -> tuple[int, int, int, int]:
return self.x, self.y, self.width, self.height
def available_monitors(root: tk.Misc | None = None) -> list[MonitorBounds]:
try:
from screeninfo import get_monitors
monitors = [
MonitorBounds(
int(monitor.x),
int(monitor.y),
int(monitor.width),
int(monitor.height),
bool(getattr(monitor, "is_primary", False)),
)
for monitor in get_monitors()
]
if monitors:
return monitors
except Exception:
pass
if root is None:
return []
return [
MonitorBounds(
int(root.winfo_vrootx()),
int(root.winfo_vrooty()),
int(root.winfo_vrootwidth()),
int(root.winfo_vrootheight()),
True,
)
]
def preferred_monitor(
root: tk.Misc,
saved_bounds: tuple[int, int, int, int] | None = None,
) -> MonitorBounds:
monitors = available_monitors(root)
if saved_bounds:
for monitor in monitors:
if monitor.as_tuple() == saved_bounds:
return monitor
return next((monitor for monitor in monitors if monitor.primary), monitors[0])
def monitor_for_geometry(root: tk.Misc, geometry: str) -> MonitorBounds:
monitors = available_monitors(root)
parsed = parse_geometry(geometry)
if not parsed:
return next((monitor for monitor in monitors if monitor.primary), monitors[0])
width, height, x, y = parsed
center_x, center_y = x + width // 2, y + height // 2
containing = [
monitor
for monitor in monitors
if monitor.x <= center_x < monitor.x + monitor.width
and monitor.y <= center_y < monitor.y + monitor.height
]
if containing:
return containing[0]
return min(monitors, key=lambda monitor: _distance_squared(monitor.center, (center_x, center_y)))
def centered_geometry(width: int, height: int, monitor: MonitorBounds) -> str:
fitted_width = min(width, max(640, monitor.width - 80))
fitted_height = min(height, max(480, monitor.height - 80))
x = monitor.x + (monitor.width - fitted_width) // 2
y = monitor.y + (monitor.height - fitted_height) // 2
return format_geometry(fitted_width, fitted_height, x, y)
def ensure_visible_geometry(geometry: str, monitor: MonitorBounds) -> str:
parsed = parse_geometry(geometry)
if not parsed:
return centered_geometry(1500, 860, monitor)
width, height, x, y = parsed
width = min(width, max(640, monitor.width - 40))
height = min(height, max(480, monitor.height - 40))
x = min(max(x, monitor.x), monitor.x + monitor.width - width)
y = min(max(y, monitor.y), monitor.y + monitor.height - height)
return format_geometry(width, height, x, y)
def parse_geometry(geometry: str) -> tuple[int, int, int, int] | None:
match = re.fullmatch(r"(\d+)x(\d+)([+-]\d+)([+-]\d+)", geometry.strip())
if not match:
return None
return tuple(int(value) for value in match.groups()) # type: ignore[return-value]
def format_geometry(width: int, height: int, x: int, y: int) -> str:
return f"{width}x{height}{x:+d}{y:+d}"
def _distance_squared(left: tuple[int, int], right: tuple[int, int]) -> int:
return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2
+374
View File
@@ -0,0 +1,374 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig
from ccma.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
)
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore
class OptionsDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
config: AppConfig,
repository: MemberRepository,
on_saved: Callable[[bool], None] | None = None,
):
super().__init__(master)
self.config_obj = config
self.repository = repository
self.on_saved = on_saved
self.icons = IconStore(self)
self.store_var = tk.StringVar(value=config.store_path)
self.gnucash_var = tk.StringVar(value=config.gnucash_path)
self.theme_var = tk.StringVar(value=config.theme_mode)
self.housekeeper_var = tk.BooleanVar(value=config.run_housekeeper_on_startup)
self.birthday_before_var = tk.StringVar(value=str(config.birthday_days_before))
self.birthday_after_var = tk.StringVar(value=str(config.birthday_days_after))
self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before))
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals)
number_policy = repository.get_member_number_policy()
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
self.number_preview_var = tk.StringVar()
self.title("Optionen")
self.geometry("900x650")
self.minsize(760, 540)
self.transient(master.winfo_toplevel())
self.grab_set()
self.resizable(True, True)
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self._center_on_parent)
def _build_ui(self) -> None:
root = ttk.Frame(self, padding=16)
root.grid(row=0, column=0, sticky="nsew")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
root.columnconfigure(0, weight=1)
root.rowconfigure(0, weight=1)
notebook = ttk.Notebook(root)
notebook.grid(row=0, column=0, sticky="nsew")
paths = ttk.Frame(notebook, padding=16)
appearance = ttk.Frame(notebook, padding=16)
member_numbers = ttk.Frame(notebook, padding=16)
automation = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung")
notebook.add(member_numbers, text="Mitgliedsnummern")
notebook.add(automation, text="Hausmeister")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
self._build_member_numbers(member_numbers)
self._build_automation(automation)
buttons = ttk.Frame(root)
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
save_icon = self.icons.get("content-save", 16)
save = ttk.Button(
buttons,
text="Speichern",
image=save_icon,
compound="left",
style="Accent.TButton",
command=self._save,
)
save.image = save_icon
save.pack(side="left")
def _build_paths(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
folder_icon = self.icons.get("folder-open", 18)
file_icon = self.icons.get("file-search", 18) or folder_icon
ttk.Label(parent, text="Mitglieder-Store").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(parent, textvariable=self.store_var, width=62).grid(row=0, column=1, sticky="ew", pady=6)
browse_store = ttk.Button(
parent,
text="Auswählen",
image=folder_icon,
compound="left",
command=self._choose_store,
)
browse_store.image = folder_icon
browse_store.grid(row=0, column=2, padx=(8, 0), pady=6)
ttk.Label(
parent,
text="Ein Wechsel wird nach einem Neustart aktiv. Neue Verzeichnisse können angelegt werden.",
style="Muted.TLabel",
wraplength=620,
).grid(row=1, column=1, columnspan=2, sticky="w", pady=(0, 12))
ttk.Label(parent, text="GnuCash-Datei").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(parent, textvariable=self.gnucash_var).grid(row=2, column=1, sticky="ew", pady=6)
browse_gnucash = ttk.Button(
parent,
text="Auswählen",
image=file_icon,
compound="left",
command=self._choose_gnucash,
)
browse_gnucash.image = file_icon
browse_gnucash.grid(row=2, column=2, padx=(8, 0), pady=6)
ttk.Label(
parent,
text="Optional. Die Datei wird zukünftig ausschließlich lesend eingebunden.",
style="Muted.TLabel",
).grid(row=3, column=1, columnspan=2, sticky="w")
def _build_appearance(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Label(parent, text="Farbschema").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Combobox(
parent,
textvariable=self.theme_var,
values=("dark", "light"),
state="readonly",
width=18,
).grid(row=0, column=1, sticky="w", pady=6)
ttk.Label(
parent,
text="Das Theme wird direkt nach dem Speichern umgeschaltet.",
style="Muted.TLabel",
).grid(row=1, column=1, sticky="w")
def _build_automation(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Checkbutton(
parent,
text="Hausmeister beim Programmstart ausführen",
variable=self.housekeeper_var,
style="Switch",
).grid(row=0, column=0, columnspan=3, sticky="w", pady=6)
ttk.Label(
parent,
text="Der Lauf prüft nur und führt keine Mahnungen oder Statusänderungen automatisch aus.",
style="Muted.TLabel",
wraplength=620,
).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 18))
ttk.Label(parent, text="Geburtstage melden").grid(row=2, column=0, sticky="w", pady=6)
birthday_window = ttk.Frame(parent)
birthday_window.grid(row=2, column=1, sticky="w", pady=6)
ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_before_var, width=5).pack(
side="left"
)
ttk.Label(birthday_window, text="Tage vorher / ").pack(side="left", padx=(6, 0))
ttk.Spinbox(birthday_window, from_=0, to=365, textvariable=self.birthday_after_var, width=5).pack(
side="left"
)
ttk.Label(birthday_window, text="Tage nachher").pack(side="left", padx=(6, 0))
ttk.Label(parent, text="Jubiläen melden").grid(row=3, column=0, sticky="w", pady=6)
anniversary_window = ttk.Frame(parent)
anniversary_window.grid(row=3, column=1, sticky="w", pady=6)
ttk.Spinbox(
anniversary_window,
from_=0,
to=365,
textvariable=self.anniversary_before_var,
width=5,
).pack(side="left")
ttk.Label(anniversary_window, text="Tage vorher / ").pack(side="left", padx=(6, 0))
ttk.Spinbox(
anniversary_window,
from_=0,
to=365,
textvariable=self.anniversary_after_var,
width=5,
).pack(side="left")
ttk.Label(anniversary_window, text="Tage nachher").pack(side="left", padx=(6, 0))
ttk.Label(parent, text="Jubiläumsintervalle").grid(row=4, column=0, sticky="w", pady=6)
ttk.Entry(parent, textvariable=self.anniversary_intervals_var).grid(
row=4, column=1, sticky="ew", pady=6
)
ttk.Label(
parent,
text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.",
style="Muted.TLabel",
).grid(row=5, column=1, sticky="w")
def _build_member_numbers(self, parent: ttk.Frame) -> None:
parent.columnconfigure(1, weight=1)
ttk.Checkbutton(
parent,
text="Mitgliedsnummern manuell angeben",
variable=self.manual_numbers_var,
style="Switch",
command=self._update_member_number_controls,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 16))
ttk.Label(parent, text="Automatisches Pattern").grid(
row=1, column=0, sticky="w", padx=(0, 12), pady=6
)
self.pattern_entry = ttk.Entry(parent, textvariable=self.number_pattern_var, width=36)
self.pattern_entry.grid(row=1, column=1, sticky="ew", pady=6)
ttk.Label(
parent,
text="Platzhalter: {number}, {number:04d} und optional {year}",
style="Muted.TLabel",
).grid(row=2, column=1, sticky="w", pady=(0, 12))
ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
row=3, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text=(
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern "
"werden niemals doppelt erzeugt."
),
style="Muted.TLabel",
wraplength=620,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0))
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
self._update_member_number_controls()
def _update_member_number_controls(self) -> None:
if self.manual_numbers_var.get():
self.pattern_entry.state(["disabled"])
self.number_preview_var.set("MANUELLE EINGABE")
else:
self.pattern_entry.state(["!disabled"])
self._update_number_preview()
def _update_number_preview(self) -> None:
if self.manual_numbers_var.get():
return
pattern = self.number_pattern_var.get()
try:
preview = self.repository.preview_member_number(pattern)
except RepositoryError as exc:
preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview)
def _choose_store(self) -> None:
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
initial = current if current.is_dir() else current.parent
selected = filedialog.askdirectory(
parent=self,
title="Mitglieder-Store auswählen oder anlegen",
initialdir=str(initial),
mustexist=False,
)
if selected:
self.store_var.set(selected)
def _choose_gnucash(self) -> None:
current = Path(self.gnucash_var.get()).expanduser() if self.gnucash_var.get().strip() else Path.home()
initial = current.parent if current.suffix else current
selected = filedialog.askopenfilename(
parent=self,
title="GnuCash-Datei auswählen",
initialdir=str(initial),
filetypes=(
("GnuCash-Dateien", "*.gnucash *.xac"),
("Alle Dateien", "*"),
),
)
if selected:
self.gnucash_var.set(selected)
def _save(self) -> None:
number_mode = "manual" if self.manual_numbers_var.get() else "automatic"
number_pattern = self.number_pattern_var.get().strip()
try:
validate_member_number_pattern(number_pattern)
except RepositoryError as exc:
messagebox.showerror("Ungültiges Mitgliedsnummern-Pattern", str(exc), parent=self)
return
try:
birthday_before = _parse_day_window(self.birthday_before_var.get(), "Geburtstage vorher")
birthday_after = _parse_day_window(self.birthday_after_var.get(), "Geburtstage nachher")
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
return
store_text = self.store_var.get().strip()
if not store_text:
messagebox.showerror(
"Ungültige Einstellung", "Ein Mitglieder-Store ist erforderlich.", parent=self
)
return
store = Path(store_text).expanduser().resolve()
if store.exists() and not store.is_dir():
messagebox.showerror(
"Ungültiger Store", "Der Mitglieder-Store ist kein Verzeichnis.", parent=self
)
return
if not store.exists():
create = messagebox.askyesno(
"Store anlegen",
f"Das Verzeichnis existiert noch nicht:\n\n{store}\n\nJetzt anlegen?",
parent=self,
)
if not create:
return
try:
store.mkdir(parents=True)
except OSError as exc:
messagebox.showerror("Store konnte nicht angelegt werden", str(exc), parent=self)
return
gnucash_text = self.gnucash_var.get().strip()
gnucash = Path(gnucash_text).expanduser().resolve() if gnucash_text else None
if gnucash and not gnucash.is_file():
messagebox.showerror(
"Ungültige GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self
)
return
old_store = Path(self.config_obj.store_path).expanduser().resolve()
store_changed = old_store != store
self.config_obj.store_path = str(store)
self.config_obj.gnucash_path = str(gnucash) if gnucash else ""
self.config_obj.theme_mode = self.theme_var.get()
self.config_obj.run_housekeeper_on_startup = self.housekeeper_var.get()
self.config_obj.birthday_days_before = birthday_before
self.config_obj.birthday_days_after = birthday_after
self.config_obj.anniversary_days_before = anniversary_before
self.config_obj.anniversary_days_after = anniversary_after
self.config_obj.anniversary_intervals = anniversary_intervals
try:
self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return
if self.on_saved:
self.on_saved(store_changed)
self.destroy()
def _center_on_parent(self) -> None:
self.update_idletasks()
parent = self.master.winfo_toplevel()
x = parent.winfo_rootx() + max(0, (parent.winfo_width() - self.winfo_width()) // 2)
y = parent.winfo_rooty() + max(0, (parent.winfo_height() - self.winfo_height()) // 2)
self.geometry(f"+{x}+{y}")
def _parse_day_window(value: str, label: str) -> int:
try:
parsed = int(value.strip())
except ValueError as exc:
raise ValueError(f"{label} muss eine ganze Zahl sein.") from exc
if not 0 <= parsed <= 365:
raise ValueError(f"{label} muss zwischen 0 und 365 Tagen liegen.")
return parsed
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import threading
import tkinter as tk
from collections.abc import Callable
from dataclasses import dataclass
from queue import Empty, Queue
from tkinter import ttk
from ccma import __version__
from ccma.domain.models import HousekeeperFinding
from ccma.services.housekeeper import Housekeeper, HousekeeperSettings
from ccma.storage.repository import MemberRepository
from ccma.ui.monitors import MonitorBounds, preferred_monitor
@dataclass(slots=True)
class StartupResult:
repository: MemberRepository
validation_errors: list[str]
findings: list[HousekeeperFinding]
class SplashScreen(tk.Toplevel):
def __init__(
self,
master: tk.Tk,
repository: MemberRepository,
on_complete: Callable[[StartupResult], None],
on_error: Callable[[Exception], None],
*,
run_housekeeper: bool = True,
monitor: MonitorBounds | None = None,
housekeeper_settings: HousekeeperSettings | None = None,
):
super().__init__(master)
self.repository = repository
self.on_complete = on_complete
self.on_error = on_error
self.run_housekeeper = run_housekeeper
self.monitor = monitor
self.housekeeper_settings = housekeeper_settings
self._messages: Queue[tuple[str, object]] = Queue()
self.overrideredirect(True)
self.resizable(False, False)
self.configure(background="#0b1117")
self.canvas: tk.Canvas
self.status_item: int
self._build_ui()
self._center()
self.after(120, self._start)
def _build_ui(self) -> None:
width, height = 620, 330
self.geometry(f"{width}x{height}")
self.canvas = tk.Canvas(
self,
width=width,
height=height,
highlightthickness=0,
background="#0b1117",
)
self.canvas.pack(fill="both", expand=True)
self.canvas.create_rectangle(0, 0, width, 6, fill="#00d084", outline="")
self.canvas.create_text(
34,
58,
anchor="w",
text="CCMA",
fill="#00d084",
font=("TkFixedFont", 30, "bold"),
)
self.canvas.create_text(
34,
101,
anchor="w",
text="CHAOTIC CREATURE MEMBER ADMINISTRATION",
fill="#d7e0e8",
font=("TkFixedFont", 12, "bold"),
)
self.canvas.create_text(
34,
139,
anchor="w",
text="Chaos Computer Club Mannheim e.V.",
fill="#7f8e9b",
font=("TkDefaultFont", 10),
)
self.canvas.create_text(
width - 34, 58, anchor="e", text=f"v{__version__}", fill="#7f8e9b", font=("TkFixedFont", 10)
)
self.status_item = self.canvas.create_text(
34,
245,
anchor="w",
text="Initialisiere …",
fill="#d7e0e8",
font=("TkFixedFont", 10),
)
self.progress = ttk.Progressbar(self, mode="indeterminate")
self.canvas.create_window(34, 280, anchor="w", width=552, window=self.progress)
def _center(self) -> None:
self.update_idletasks()
width, height = self.winfo_width(), self.winfo_height()
monitor = self.monitor or preferred_monitor(self)
pointer_x, pointer_y = monitor.center
x, y = centered_position(
width=width,
height=height,
pointer_x=pointer_x,
pointer_y=pointer_y,
screen_x=monitor.x,
screen_y=monitor.y,
screen_width=monitor.width,
screen_height=monitor.height,
)
self.geometry(f"{width}x{height}+{x}+{y}")
def _start(self) -> None:
self.progress.start(10)
def worker() -> None:
try:
self._messages.put(("status", "Öffne Mitglieder-Store …"))
self.repository.initialize()
self._messages.put(("status", "Validiere Mitgliederakten …"))
errors = self.repository.validate()
self._messages.put(("status", "Baue Suchindex …"))
self.repository.list_members()
findings = []
if self.run_housekeeper:
self._messages.put(("status", "Starte Hausmeister …"))
findings = Housekeeper(self.repository, self.housekeeper_settings).run()
result = StartupResult(self.repository, errors, findings)
self._messages.put(("result", result))
except Exception as exc:
self._messages.put(("error", exc))
threading.Thread(target=worker, name="ccma-startup", daemon=True).start()
self.after(30, self._poll_messages)
def _poll_messages(self) -> None:
keep_polling = True
try:
while True:
kind, payload = self._messages.get_nowait()
if kind == "status":
self._set_status(str(payload))
elif kind == "result":
keep_polling = False
self._finish(payload) # type: ignore[arg-type]
elif kind == "error":
keep_polling = False
self._fail(payload) # type: ignore[arg-type]
except Empty:
pass
if keep_polling and self.winfo_exists():
self.after(30, self._poll_messages)
def _finish(self, result: StartupResult) -> None:
self.progress.stop()
self._set_status(f"Bereit · {len(result.findings)} Vorgänge benötigen Aufmerksamkeit")
self.after(350, lambda: self._complete(result))
def _set_status(self, text: str) -> None:
self.canvas.itemconfigure(self.status_item, text=text)
def _complete(self, result: StartupResult) -> None:
self.destroy()
self.on_complete(result)
def _fail(self, error: Exception) -> None:
self.progress.stop()
self.destroy()
self.on_error(error)
def centered_position(
*,
width: int,
height: int,
pointer_x: int,
pointer_y: int,
screen_x: int,
screen_y: int,
screen_width: int,
screen_height: int,
) -> tuple[int, int]:
"""Center around the pointer and keep the complete window on the virtual desktop."""
maximum_x = screen_x + max(0, screen_width - width)
maximum_y = screen_y + max(0, screen_height - height)
x = min(max(pointer_x - width // 2, screen_x), maximum_x)
y = min(max(pointer_y - height // 2, screen_y), maximum_y)
return x, y
+77
View File
@@ -0,0 +1,77 @@
from pathlib import Path
from tkinter import TclError, ttk
def load_theme(root, mode: str) -> str:
style = ttk.Style(root)
variant = "light" if mode == "light" else "dark"
theme_name = f"forest-{variant}"
theme_path = (
Path(__file__).resolve().parent.parent / "assets" / "themes" / "forest" / f"forest-{variant}.tcl"
)
if theme_path.exists():
try:
if theme_name not in style.theme_names():
root.tk.call("source", str(theme_path))
style.theme_use(theme_name)
_configure_ccma_styles(style, variant)
return theme_name
except TclError:
pass
style.theme_use("clam")
_configure_fallback(style, variant)
_configure_ccma_styles(style, variant)
return "clam"
def _configure_fallback(style: ttk.Style, variant: str) -> None:
dark = variant == "dark"
background = "#14181f" if dark else "#f4f6f8"
surface = "#20262f" if dark else "#ffffff"
foreground = "#e6edf3" if dark else "#20252b"
accent = "#00a884" if dark else "#087f5b"
style.configure(".", background=background, foreground=foreground)
style.configure("TFrame", background=background)
style.configure("TLabel", background=background, foreground=foreground)
style.configure("TButton", background=surface, foreground=foreground)
style.configure("TEntry", fieldbackground=surface, foreground=foreground)
style.configure("Treeview", background=surface, fieldbackground=surface, foreground=foreground)
style.map("Treeview", background=[("selected", accent)], foreground=[("selected", "#ffffff")])
def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
dark = variant == "dark"
background = "#1b1f23" if dark else "#ffffff"
foreground = "#f0f4f8" if dark else "#202124"
muted = "#9aa4ad" if dark else "#5f6368"
accent = "#00d084" if dark else "#087f5b"
warning = "#ffb454"
danger = "#ff6b6b"
style.configure("Ribbon.TFrame", padding=(12, 9))
style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold"))
style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold"))
style.configure("Mono.TLabel", font=("TkFixedFont", 10), foreground=muted)
style.configure("Muted.TLabel", foreground=muted)
style.configure("Status.TLabel", font=("TkFixedFont", 9), foreground=muted)
style.configure("TimelineHeader.TLabel", font=("TkFixedFont", 11, "bold"), foreground=accent)
style.configure("Error.TLabel", foreground=danger)
style.configure("Warning.TLabel", foreground=warning)
style.configure("Accent.TButton", font=("TkDefaultFont", 10, "bold"))
style.configure("Card.TFrame", background=background, relief="solid", borderwidth=1)
style.configure("CardTitle.TLabel", background=background, foreground=muted, font=("TkFixedFont", 10))
style.configure(
"CardValue.TLabel", background=background, foreground=foreground, font=("TkDefaultFont", 15, "bold")
)
style.configure(
"CardError.TLabel", background=background, foreground=danger, font=("TkDefaultFont", 15, "bold")
)
style.configure(
"CardWarning.TLabel", background=background, foreground=warning, font=("TkDefaultFont", 15, "bold")
)
style.configure(
"Timeline.Treeview",
rowheight=42,
background=background,
fieldbackground=background,
foreground=foreground,
)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import tkinter as tk
def is_maximized(window: tk.Tk) -> bool:
try:
if window.state() == "zoomed":
return True
except tk.TclError:
pass
try:
return bool(int(window.attributes("-zoomed")))
except (tk.TclError, TypeError, ValueError):
return False
def maximize(window: tk.Tk) -> bool:
try:
window.state("zoomed")
if is_maximized(window):
return True
except tk.TclError:
pass
try:
window.attributes("-zoomed", True)
return bool(int(window.attributes("-zoomed")))
except (tk.TclError, TypeError, ValueError):
return False
+248
View File
@@ -0,0 +1,248 @@
import tkinter as tk
from collections import Counter
from collections.abc import Callable
from tkinter import ttk
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member
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=1, rowspan=2)
tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings")
for key, title, width in (
("number", "Nummer", 90),
("name", "Name", 220),
("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.display_name,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
tree.bind("<Double-1>", lambda _event: self._open_selected(tree))
tree.bind("<Return>", 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(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="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)
self.tree = ttk.Treeview(
self, columns=("number", "name", "email", "birth", "status"), show="headings"
)
for key, title, width in (
("number", "Nummer", 110),
("name", "Name", 230),
("email", "E-Mail-Adresse", 270),
("birth", "Geburtsdatum", 120),
("status", "Status", 170),
):
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("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<Return>", lambda _event: self._open_selected())
self.refresh(self.members)
def refresh(self, members: list[Member]) -> None:
self.members = members
self.tree.delete(*self.tree.get_children())
self.count_var.set(f"{len(members)} Mitglieder")
for member in members:
self.tree.insert(
"",
"end",
iid=member.member_id,
values=(
member.member_number,
member.display_name,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
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_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_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="Prüfend, keine Aktionen werden automatisch ausgeführt", 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("<Double-1>", lambda _event: self._open_selected())
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.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 _open_selected(self) -> None:
selected = self.tree.selection()
if selected:
self.on_open_member(self.findings[int(selected[0])].member_id)