From 04c23fbdf9cfcb6064b7362ce8cd9494f7128dc6 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 27 Jun 2026 10:36:03 +0200 Subject: [PATCH] Refine member and asset detail layouts --- src/ccma/ui/asset_tab.py | 149 +++++++++++++++++++++++++++----- src/ccma/ui/member_tab.py | 173 +++++++++++++++++++++++++++++++------- src/ccma/ui/messages.py | 80 ++++++++++++++++++ src/ccma/ui/scrolling.py | 68 +++++++++++++++ src/ccma/ui/theme.py | 34 ++++++++ 5 files changed, 452 insertions(+), 52 deletions(-) create mode 100644 src/ccma/ui/messages.py create mode 100644 src/ccma/ui/scrolling.py diff --git a/src/ccma/ui/asset_tab.py b/src/ccma/ui/asset_tab.py index 159c936..0779f04 100644 --- a/src/ccma/ui/asset_tab.py +++ b/src/ccma/ui/asset_tab.py @@ -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("", 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("", lambda _event: self._open_selected_asset_claim()) self.asset_claims.bind("", 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: diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index c277810..2b9d38a 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("<>", 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("", 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: diff --git a/src/ccma/ui/messages.py b/src/ccma/ui/messages.py new file mode 100644 index 0000000..ee79dd1 --- /dev/null +++ b/src/ccma/ui/messages.py @@ -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( + "", + 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") diff --git a/src/ccma/ui/scrolling.py b/src/ccma/ui/scrolling.py new file mode 100644 index 0000000..6fc0cbc --- /dev/null +++ b/src/ccma/ui/scrolling.py @@ -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("", self._update_scrollregion, add="+") + self.canvas.bind("", self._fit_content_width, add="+") + self.bind("", self._bind_mousewheel, add="+") + self.bind("", self._unbind_mousewheel, add="+") + self.content.bind("", self._bind_mousewheel, add="+") + self.content.bind("", 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("", self._on_mousewheel, add="+") + self.canvas.bind_all("", self._on_mousewheel, add="+") + self.canvas.bind_all("", self._on_mousewheel, add="+") + + def _unbind_mousewheel(self, _event: tk.Event | None = None) -> None: + self.canvas.unbind_all("") + self.canvas.unbind_all("") + self.canvas.unbind_all("") + + 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") diff --git a/src/ccma/ui/theme.py b/src/ccma/ui/theme.py index da3e929..01f614f 100644 --- a/src/ccma/ui/theme.py +++ b/src/ccma/ui/theme.py @@ -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), + )