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
+127 -22
View File
@@ -7,7 +7,9 @@ from tkinter import messagebox, ttk
from ccma.domain.models import ASSET_STATUS_LABELS, Asset, Event from ccma.domain.models import ASSET_STATUS_LABELS, Asset, Event
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import AssetClaimDialog from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
class AssetTab(ttk.Frame): class AssetTab(ttk.Frame):
@@ -40,18 +42,35 @@ class AssetTab(ttk.Frame):
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=1)
header = ttk.Frame(self) header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1) header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar() self.title_var = tk.StringVar()
self.subtitle_var = tk.StringVar() self.subtitle_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") self.id_var = tk.StringVar()
ttk.Label(header, textvariable=self.subtitle_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") title_column = ttk.Frame(header)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2, sticky="e") 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(title_column, textvariable=self.subtitle_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w"
)
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.on_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 = 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.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 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.details_pane, weight=2)
@@ -74,15 +93,23 @@ class AssetTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None: def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1) parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// ASSET", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent) notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew") notebook.grid(row=1, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16) data_tab = self._create_form_tab(notebook, "Stammdaten")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
action_buttons = ttk.Frame(actions)
action_buttons.grid(row=0, column=0, sticky="e")
finance_tab = ttk.Frame(notebook, padding=16) finance_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(finance_tab, text="Forderungen") notebook.add(finance_tab, text="Forderungen")
fields = [ fields = [
("UUID / Ordner-ID", "asset_id"),
("Bezeichnung", "label"), ("Bezeichnung", "label"),
("Kategorie", "category"), ("Kategorie", "category"),
("Inventarnummer", "inventory_number"), ("Inventarnummer", "inventory_number"),
@@ -94,6 +121,8 @@ class AssetTab(ttk.Frame):
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42) entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42)
entry.grid(row=row, column=1, sticky="ew", pady=5) entry.grid(row=row, column=1, sticky="ew", pady=5)
if key == "asset_id":
entry.configure(state="readonly")
if key == "deposit_amount_default": if key == "deposit_amount_default":
self.deposit_entry = entry self.deposit_entry = entry
self.variables["status"] = tk.StringVar() self.variables["status"] = tk.StringVar()
@@ -108,23 +137,48 @@ class AssetTab(ttk.Frame):
) )
self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5) self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5)
self.holder_var = tk.StringVar() self.holder_var = tk.StringVar()
ttk.Label(data_tab, text="Aktueller Halter").grid(row=holder_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(data_tab, text="Aktueller Halter").grid(
ttk.Label(data_tab, textvariable=self.holder_var, style="TimelineHeader.TLabel").grid( row=holder_row + 1,
row=holder_row + 1, column=1, sticky="w", pady=5 column=0,
sticky="w",
pady=5,
padx=(0, 12),
)
self.holder_label = ttk.Label(
data_tab,
textvariable=self.holder_var,
style="TimelineHeader.TLabel",
)
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
ttk.Label(data_tab, text="Interne Notiz").grid(
row=holder_row + 2,
column=0,
sticky="nw",
pady=5,
padx=(0, 12),
) )
ttk.Label(data_tab, text="Interne Notiz").grid(row=holder_row + 2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word") self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5) self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
data_tab.columnconfigure(1, weight=1) data_tab.columnconfigure(1, weight=1)
actions = ttk.Frame(data_tab) self.issue_button = ttk.Button(
actions.grid(row=holder_row + 3, column=1, sticky="e", pady=(16, 0)) action_buttons,
self.open_member_button = ttk.Button(actions, text="Mitglied öffnen", command=self._open_holder_member) text="Ausgeben",
self.open_member_button.pack(side="left", padx=(0, 8)) command=lambda: self.on_issue_asset(self.asset_id),
self.issue_button = ttk.Button(actions, text="Ausgeben", command=lambda: self.on_issue_asset(self.asset_id)) )
self.issue_button.pack(side="left", padx=(0, 8)) self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(actions, text="Zurücknehmen", command=lambda: self.on_return_asset(self.asset_id)) self.return_button = ttk.Button(
action_buttons,
text="Zurücknehmen",
command=lambda: self.on_return_asset(self.asset_id),
)
self.return_button.pack(side="left", padx=(0, 8)) self.return_button.pack(side="left", padx=(0, 8))
ttk.Button(actions, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left") ttk.Button(
action_buttons,
text="Speichern",
style="Accent.TButton",
command=self._save,
).pack(side="left")
finance_tab.columnconfigure(0, weight=1) finance_tab.columnconfigure(0, weight=1)
self.finance_summary_var = tk.StringVar() self.finance_summary_var = tk.StringVar()
@@ -157,6 +211,15 @@ class AssetTab(ttk.Frame):
self.asset_claims.bind("<Double-1>", lambda _event: self._open_selected_asset_claim()) self.asset_claims.bind("<Double-1>", lambda _event: self._open_selected_asset_claim())
self.asset_claims.bind("<Return>", lambda _event: self._open_selected_asset_claim()) self.asset_claims.bind("<Return>", lambda _event: self._open_selected_asset_claim())
def _create_form_tab(self, notebook: ttk.Notebook, 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)
return scroller.content
def _build_timeline(self, parent: ttk.Frame) -> None: def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1) parent.rowconfigure(1, weight=1)
@@ -186,6 +249,20 @@ class AssetTab(ttk.Frame):
holder = self._holder_label() holder = self._holder_label()
status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()) status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())
self.subtitle_var.set(f"{self.asset.inventory_number or ''} · {status} · {holder}") self.subtitle_var.set(f"{self.asset.inventory_number or ''} · {status} · {holder}")
self.id_var.set(f"UUID: {self.asset.asset_id}")
warnings = self.repository.asset_hash_warnings(self.asset_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
self.variables["asset_id"].set(self.asset.asset_id)
self.variables["label"].set(self.asset.label) self.variables["label"].set(self.asset.label)
self.variables["category"].set(self.asset.category) self.variables["category"].set(self.asset.category)
self.variables["inventory_number"].set(self.asset.inventory_number) self.variables["inventory_number"].set(self.asset.inventory_number)
@@ -197,7 +274,7 @@ class AssetTab(ttk.Frame):
self.notes_text.insert("1.0", self.asset.notes) self.notes_text.insert("1.0", self.asset.notes)
self.holder_var.set(holder) self.holder_var.set(holder)
issued = bool(self.asset.current_holder_member_id) issued = bool(self.asset.current_holder_member_id)
self.open_member_button.configure(state="normal" if issued else "disabled") self.holder_label.configure(cursor="hand2" if issued else "")
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled") self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if issued else "disabled") self.return_button.configure(state="normal" if issued else "disabled")
self.status_box.configure(state="disabled" if issued else "readonly") self.status_box.configure(state="disabled" if issued else "readonly")
@@ -255,6 +332,13 @@ class AssetTab(ttk.Frame):
) )
def _save(self) -> None: def _save(self) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
self.asset.label = self.variables["label"].get().strip() self.asset.label = self.variables["label"].get().strip()
self.asset.category = self.variables["category"].get().strip() self.asset.category = self.variables["category"].get().strip()
self.asset.inventory_number = self.variables["inventory_number"].get().strip() self.asset.inventory_number = self.variables["inventory_number"].get().strip()
@@ -265,12 +349,33 @@ class AssetTab(ttk.Frame):
self.asset.status = _asset_status_key(self.variables["status"].get()) self.asset.status = _asset_status_key(self.variables["status"].get())
try: try:
self.repository.save_asset(self.asset) self.repository.save_asset(self.asset)
self.repository.refresh_asset_record_hashes(self.asset_id)
except RepositoryError as exc: except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self) messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self)
return return
self.refresh() self.refresh()
self.on_changed() 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_asset_record_hashes(self.asset_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None: def _add_comment(self) -> None:
text = self.comment_var.get().strip() text = self.comment_var.get().strip()
if not text: if not text:
+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.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.file_open import open_path from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key 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 = ( CLAIM_TABLE_COLUMNS = (
@@ -61,30 +64,46 @@ class MemberTab(ttk.Frame):
self.on_return_asset = on_return_asset self.on_return_asset = on_return_asset
self.member = repository.get_member(member_id) self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {} 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.notes_text: tk.Text | None = None
self._build_ui() self._build_ui()
self.refresh() self.refresh()
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=1)
header = ttk.Frame(self) header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1) header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar() self.title_var = tk.StringVar()
self.status_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" 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) row=1, column=0, sticky="w", pady=(3, 0)
) )
ttk.Button(header, text="Tab schließen", command=self.on_close).grid( ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=0, column=1, rowspan=2, sticky="e" 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 = 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.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 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.details_pane, weight=2)
@@ -107,23 +126,34 @@ class MemberTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None: def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) 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 = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew") self.details_notebook = notebook
data_tab = ttk.Frame(notebook, padding=16) notebook.grid(row=1, column=0, sticky="nsew")
address_tab = ttk.Frame(notebook, padding=16) data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
banking_tab = ttk.Frame(notebook, padding=16) 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) contribution_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16) assets_tab = ttk.Frame(notebook, padding=16)
documents_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(contribution_tab, text="Forderungen")
notebook.add(assets_tab, text="Assets") notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente") notebook.add(documents_tab, text="Dokumente")
fields = [ fields = [
("UUID / Ordner-ID", "member_id"),
("Mitgliedsnummer", "member_number"), ("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"), ("Vorname", "first_name"),
("Nachname", "last_name"), ("Nachname", "last_name"),
@@ -136,7 +166,7 @@ class MemberTab(ttk.Frame):
] ]
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
variable = tk.StringVar() 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)) ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date": if key == "birth_date":
birth_row = ttk.Frame(data_tab) 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())) "write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
) )
else: 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( ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
row=row, column=1, sticky="ew", pady=5 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.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox( ttk.Combobox(
data_tab, 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 = 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.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) 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 = ( address_fields = (
("Straße und Hausnummer", "street"), ("Straße und Hausnummer", "street"),
@@ -183,14 +211,11 @@ class MemberTab(ttk.Frame):
) )
address_tab.columnconfigure(1, weight=1) address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields): 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.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( ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5 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 = ( banking_fields = (
("Kontoinhaber", "account_holder"), ("Kontoinhaber", "account_holder"),
@@ -202,22 +227,18 @@ class MemberTab(ttk.Frame):
) )
banking_tab.columnconfigure(1, weight=1) banking_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(banking_fields): 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.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( ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5 row=row, column=1, sticky="ew", pady=5
) )
self.variables["mandate_active"] = tk.BooleanVar() self._add_variable("mandate_active", tk.BooleanVar(), "banking")
ttk.Checkbutton( ttk.Checkbutton(
banking_tab, banking_tab,
text="SEPA-Lastschriftmandat ist aktiv", text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"], variable=self.variables["mandate_active"],
style="Switch", style="Switch",
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5)) ).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.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1) contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar() self.contribution_summary = tk.StringVar()
@@ -308,6 +329,53 @@ class MemberTab(ttk.Frame):
self.documents.bind("<Return>", lambda _event: self._open_selected_document()) self.documents.bind("<Return>", lambda _event: self._open_selected_document())
self.document_paths: dict[str, Path] = {} 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: def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1) parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, 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) ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None: def refresh(self) -> None:
self._loading = True
self.member = self.repository.get_member(self.member_id) self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}") 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.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 = { date_fields = {
"birth_date", "birth_date",
"accepted_at", "accepted_at",
@@ -351,6 +433,9 @@ class MemberTab(ttk.Frame):
if self.notes_text is not None: if self.notes_text is not None:
self.notes_text.delete("1.0", "end") self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.member.notes) 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_events()
self._refresh_contributions() self._refresh_contributions()
self._refresh_assets() self._refresh_assets()
@@ -460,6 +545,13 @@ class MemberTab(ttk.Frame):
) )
def _save(self) -> None: 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(): for key, variable in self.variables.items():
raw_value = variable.get() raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value 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() self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
try: try:
self.repository.save_member(self.member) self.repository.save_member(self.member)
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc: except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return return
self.refresh() self.refresh()
self.on_changed() 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: def _add_comment(self) -> None:
text = self.comment_var.get().strip() text = self.comment_var.get().strip()
if not text: if not text:
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from tkinter import ttk
MessageType = str
@dataclass(frozen=True)
class MessageAction:
label: str
callback: Callable[[], None]
@dataclass(frozen=True)
class TabMessage:
message_type: MessageType
text: str
action: MessageAction | None = None
_TYPE_STYLES = {
"error": "Error",
"notification": "Notification",
"warning": "Warning",
"info": "Info",
}
class MessageBannerList(ttk.Frame):
def __init__(self, master: tk.Misc):
super().__init__(master)
self.columnconfigure(0, weight=1)
self._messages: list[TabMessage] = []
def set_messages(self, messages: Iterable[TabMessage]) -> None:
self._messages = [message for message in messages if message.text.strip()]
self._render()
if self._messages:
self.grid()
else:
self.grid_remove()
def _render(self) -> None:
for child in self.winfo_children():
child.destroy()
for row_index, message in enumerate(self._messages):
style_name = _message_style_name(message.message_type)
banner = ttk.Frame(self, style=f"Message{style_name}Border.TFrame", padding=1)
banner.grid(row=row_index, column=0, sticky="ew", pady=(0, 6))
banner.columnconfigure(0, weight=1)
body = ttk.Frame(banner, style=f"Message{style_name}.TFrame", padding=(10, 8))
body.grid(row=0, column=0, sticky="ew")
body.columnconfigure(0, weight=1)
label = ttk.Label(
body,
text=message.text,
style=f"Message{style_name}.TLabel",
wraplength=900,
justify="left",
)
label.grid(row=0, column=0, sticky="ew")
body.bind(
"<Configure>",
lambda event, target=label: target.configure(wraplength=max(240, event.width - 180)),
add="+",
)
if message.action is not None:
ttk.Button(
body,
text=message.action.label,
command=message.action.callback,
).grid(row=0, column=1, sticky="e", padx=(12, 0))
def _message_style_name(message_type: MessageType) -> str:
return _TYPE_STYLES.get(message_type.casefold(), "Info")
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
class ScrollableFrame(ttk.Frame):
def __init__(self, master: tk.Misc, *, padding: int | tuple[int, ...] = 0):
super().__init__(master)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.canvas = tk.Canvas(self, highlightthickness=0, borderwidth=0)
self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
self.content = ttk.Frame(self.canvas, padding=padding)
self._content_window = self.canvas.create_window((0, 0), window=self.content, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.grid(row=0, column=0, sticky="nsew")
self.content.bind("<Configure>", self._update_scrollregion, add="+")
self.canvas.bind("<Configure>", self._fit_content_width, add="+")
self.bind("<Enter>", self._bind_mousewheel, add="+")
self.bind("<Leave>", self._unbind_mousewheel, add="+")
self.content.bind("<Enter>", self._bind_mousewheel, add="+")
self.content.bind("<Leave>", self._unbind_mousewheel, add="+")
self.after_idle(self._apply_canvas_background)
def _apply_canvas_background(self) -> None:
background = ttk.Style(self).lookup("TFrame", "background")
if background:
self.canvas.configure(background=background)
def _update_scrollregion(self, _event: tk.Event | None = None) -> None:
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._sync_scrollbar()
def _fit_content_width(self, event: tk.Event) -> None:
self.canvas.itemconfigure(self._content_window, width=event.width)
self._sync_scrollbar()
def _sync_scrollbar(self) -> None:
bounds = self.canvas.bbox("all")
if not bounds:
self.scrollbar.grid_remove()
return
content_height = bounds[3] - bounds[1]
canvas_height = self.canvas.winfo_height()
if canvas_height > 1 and content_height > canvas_height:
self.scrollbar.grid(row=0, column=1, sticky="ns")
else:
self.scrollbar.grid_remove()
def _bind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-4>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-5>", self._on_mousewheel, add="+")
def _unbind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.unbind_all("<MouseWheel>")
self.canvas.unbind_all("<Button-4>")
self.canvas.unbind_all("<Button-5>")
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 * int(getattr(event, "delta", 0) / 120)
self.canvas.yview_scroll(delta, "units")
+34
View File
@@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
accent = "#00d084" if dark else "#087f5b" accent = "#00d084" if dark else "#087f5b"
warning = "#ffb454" warning = "#ffb454"
danger = "#ff6b6b" danger = "#ff6b6b"
message_styles = {
"Error": (
"#5c2528" if dark else "#d04242",
"#3a1719" if dark else "#fde8e8",
"#ffd7d7" if dark else "#8a1f1f",
),
"Warning": (
"#6c4b19" if dark else "#d69b19",
"#3a2a12" if dark else "#fff4cc",
"#ffe2a8" if dark else "#7a4f00",
),
"Info": (
"#204f79" if dark else "#5b9bd8",
"#132c45" if dark else "#e5f1ff",
"#cde6ff" if dark else "#174a7c",
),
"Notification": (
"#1f5a3a" if dark else "#57ad75",
"#123222" if dark else "#e4f7ec",
"#c9f2dc" if dark else "#1f6b3d",
),
}
style.configure("Ribbon.TFrame", padding=(12, 9)) style.configure("Ribbon.TFrame", padding=(12, 9))
style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold")) style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold"))
style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold")) style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold"))
@@ -75,3 +97,15 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
fieldbackground=background, fieldbackground=background,
foreground=foreground, foreground=foreground,
) )
for name, (message_border, message_background, message_foreground) in message_styles.items():
style.configure(f"Message{name}Border.TFrame", background=message_border)
style.configure(
f"Message{name}.TFrame",
background=message_background,
)
style.configure(
f"Message{name}.TLabel",
background=message_background,
foreground=message_foreground,
font=("TkDefaultFont", 10),
)