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
+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