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 @@
|
||||
"""Tk user interface for CCMA."""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user