mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
Refine member and asset detail layouts
This commit is contained in:
+127
-22
@@ -7,7 +7,9 @@ from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, Asset, Event
|
||||
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):
|
||||
@@ -40,18 +42,35 @@ class AssetTab(ttk.Frame):
|
||||
|
||||
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.subtitle_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.subtitle_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, sticky="e")
|
||||
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(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.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)
|
||||
@@ -74,15 +93,23 @@ class AssetTab(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="// ASSET", 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)
|
||||
notebook.grid(row=1, column=0, sticky="nsew")
|
||||
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)
|
||||
notebook.add(data_tab, text="Stammdaten")
|
||||
notebook.add(finance_tab, text="Forderungen")
|
||||
|
||||
fields = [
|
||||
("UUID / Ordner-ID", "asset_id"),
|
||||
("Bezeichnung", "label"),
|
||||
("Kategorie", "category"),
|
||||
("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))
|
||||
entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||
if key == "asset_id":
|
||||
entry.configure(state="readonly")
|
||||
if key == "deposit_amount_default":
|
||||
self.deposit_entry = entry
|
||||
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.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, textvariable=self.holder_var, style="TimelineHeader.TLabel").grid(
|
||||
row=holder_row + 1, column=1, sticky="w", pady=5
|
||||
ttk.Label(data_tab, text="Aktueller Halter").grid(
|
||||
row=holder_row + 1,
|
||||
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.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
|
||||
data_tab.columnconfigure(1, weight=1)
|
||||
actions = ttk.Frame(data_tab)
|
||||
actions.grid(row=holder_row + 3, column=1, sticky="e", pady=(16, 0))
|
||||
self.open_member_button = ttk.Button(actions, text="Mitglied öffnen", command=self._open_holder_member)
|
||||
self.open_member_button.pack(side="left", padx=(0, 8))
|
||||
self.issue_button = ttk.Button(actions, text="Ausgeben", command=lambda: self.on_issue_asset(self.asset_id))
|
||||
self.issue_button = ttk.Button(
|
||||
action_buttons,
|
||||
text="Ausgeben",
|
||||
command=lambda: self.on_issue_asset(self.asset_id),
|
||||
)
|
||||
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))
|
||||
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)
|
||||
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("<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:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(1, weight=1)
|
||||
@@ -186,6 +249,20 @@ class AssetTab(ttk.Frame):
|
||||
holder = self._holder_label()
|
||||
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.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["category"].set(self.asset.category)
|
||||
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.holder_var.set(holder)
|
||||
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.return_button.configure(state="normal" if issued else "disabled")
|
||||
self.status_box.configure(state="disabled" if issued else "readonly")
|
||||
@@ -255,6 +332,13 @@ class AssetTab(ttk.Frame):
|
||||
)
|
||||
|
||||
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.category = self.variables["category"].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())
|
||||
try:
|
||||
self.repository.save_asset(self.asset)
|
||||
self.repository.refresh_asset_record_hashes(self.asset_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Asset konnte nicht gespeichert werden", 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_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:
|
||||
text = self.comment_var.get().strip()
|
||||
if not text:
|
||||
|
||||
+143
-30
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
|
||||
accent = "#00d084" if dark else "#087f5b"
|
||||
warning = "#ffb454"
|
||||
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("AppTitle.TLabel", font=("TkDefaultFont", 14, "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,
|
||||
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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user