Refine member and asset detail layouts

This commit is contained in:
Marcel Peterkau
2026-06-27 10:36:03 +02:00
parent 87e972bb43
commit 04c23fbdf9
5 changed files with 452 additions and 52 deletions
+143 -30
View File
@@ -12,8 +12,11 @@ from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS as
from ccma.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
CLAIM_TABLE_COLUMNS = (
@@ -61,30 +64,46 @@ class MemberTab(ttk.Frame):
self.on_return_asset = on_return_asset
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
self._dirty_sections: set[str] = set()
self._form_tabs: dict[str, str] = {}
self._form_tab_titles: dict[str, str] = {}
self._loading = False
self.notes_text: tk.Text | None = None
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar()
self.status_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, 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(
ttk.Label(title_column, 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"
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self._close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=1, column=0, sticky="nsew")
self.pane.grid(row=2, 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)
@@ -107,23 +126,34 @@ class MemberTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16)
address_tab = ttk.Frame(notebook, padding=16)
banking_tab = ttk.Frame(notebook, padding=16)
self.details_notebook = notebook
notebook.grid(row=1, column=0, sticky="nsew")
data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
address_tab = self._create_form_tab(notebook, "address", "Anschrift")
banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
ttk.Button(
actions,
text="Mitgliedsdaten speichern",
style="Accent.TButton",
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(address_tab, text="Anschrift")
notebook.add(banking_tab, text="Bank / SEPA")
notebook.add(contribution_tab, text="Forderungen")
notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente")
fields = [
("UUID / Ordner-ID", "member_id"),
("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"),
("Nachname", "last_name"),
@@ -136,7 +166,7 @@ class MemberTab(ttk.Frame):
]
for row, (label, key) in enumerate(fields):
variable = tk.StringVar()
self.variables[key] = variable
self._add_variable(key, variable, "data")
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)
@@ -151,11 +181,11 @@ class MemberTab(ttk.Frame):
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
)
else:
entry_state = "readonly" if key == "member_number" else "normal"
entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
row=row, column=1, sticky="ew", pady=5
)
self.variables["status"] = tk.StringVar()
self._add_variable("status", tk.StringVar(), "data")
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
data_tab,
@@ -169,10 +199,8 @@ class MemberTab(ttk.Frame):
)
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
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)
)
address_fields = (
("Straße und Hausnummer", "street"),
@@ -183,14 +211,11 @@ class MemberTab(ttk.Frame):
)
address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields):
self.variables[key] = tk.StringVar()
self._add_variable(key, tk.StringVar(), "address")
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Button(
address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save
).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0))
banking_fields = (
("Kontoinhaber", "account_holder"),
@@ -202,22 +227,18 @@ class MemberTab(ttk.Frame):
)
banking_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(banking_fields):
self.variables[key] = tk.StringVar()
self._add_variable(key, tk.StringVar(), "banking")
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self.variables["mandate_active"] = tk.BooleanVar()
self._add_variable("mandate_active", tk.BooleanVar(), "banking")
ttk.Checkbutton(
banking_tab,
text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"],
style="Switch",
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
ttk.Button(
banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save
).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0))
contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar()
@@ -308,6 +329,53 @@ class MemberTab(ttk.Frame):
self.documents.bind("<Return>", lambda _event: self._open_selected_document())
self.document_paths: dict[str, Path] = {}
def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
self._form_tabs[section] = str(tab)
self._form_tab_titles[section] = title
return scroller.content
def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
self.variables[key] = variable
self._field_sections[key] = section
variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))
def _mark_dirty(self, section: str) -> None:
if self._loading:
return
self._dirty_sections.add(section)
self._refresh_dirty_tabs()
def _mark_dirty_from_text(self, section: str) -> None:
if self.notes_text is None or not self.notes_text.edit_modified():
return
self.notes_text.edit_modified(False)
self._mark_dirty(section)
def _refresh_dirty_tabs(self) -> None:
for section, tab_id in self._form_tabs.items():
title = self._form_tab_titles[section]
suffix = " *" if section in self._dirty_sections else ""
self.details_notebook.tab(tab_id, text=f"{title}{suffix}")
def _clear_dirty(self) -> None:
self._dirty_sections.clear()
self._refresh_dirty_tabs()
def _close(self) -> None:
if self._dirty_sections and not messagebox.askokcancel(
"Ungespeicherte Änderungen",
"Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
parent=self,
):
return
self.on_close()
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
@@ -332,9 +400,23 @@ class MemberTab(ttk.Frame):
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None:
self._loading = True
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()))
self.id_var.set(f"UUID: {self.member.member_id}")
warnings = self.repository.member_hash_warnings(self.member_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
date_fields = {
"birth_date",
"accepted_at",
@@ -351,6 +433,9 @@ class MemberTab(ttk.Frame):
if self.notes_text is not None:
self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.member.notes)
self.notes_text.edit_modified(False)
self._loading = False
self._clear_dirty()
self._refresh_events()
self._refresh_contributions()
self._refresh_assets()
@@ -460,6 +545,13 @@ class MemberTab(ttk.Frame):
)
def _save(self) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
for key, variable in self.variables.items():
raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
@@ -470,12 +562,33 @@ class MemberTab(ttk.Frame):
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
try:
self.repository.save_member(self.member)
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text: