Merge pull request 'Feature/table usability fixes' (#15) from feature/table-usability-fixes into dev

Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/15
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
Marcel Peterkau
2026-08-14 22:18:14 +00:00
6 changed files with 235 additions and 76 deletions
+4 -1
View File
@@ -23,7 +23,10 @@
"Ein neuer Spenden-Tab je Mitglied erfasst Beträge, die über den Mitgliedsbeitrag hinausgehen, und erlaubt die Zuordnung vorhandener oder neuer Zahlungen zu einer Spende.",
"Beim Anlegen oder Bearbeiten einer Zahlung lassen sich offene Forderungen und Spenden direkt im selben Fenster live auswählen und mit Beträgen zuordnen, inklusive der Möglichkeit, dort sofort eine neue Spende anzulegen.",
"Die Zuordnungsübersicht im Zahlungsfenster zeigt nur noch offene oder bereits zugeordnete Forderungen und Spenden; entfernte Zuordnungen lassen sich innerhalb desselben Fensters wieder herstellen.",
"Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte."
"Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte.",
"Die Spaltenbreiten in den Tabellen für Forderungen, Zahlungen, Spenden und Hausmeister-Vorgänge sind jetzt einheitlich: Datum-, Betrag- und Statusspalten haben eine feste Breite, nur die abschließende Beschreibungs- bzw. Referenzspalte passt sich dynamisch an die Fensterbreite an.",
"Die Chronik im Mitgliedsfenster nimmt standardmäßig ein Drittel der Fensterbreite ein.",
"Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden."
]
},
{
+38
View File
@@ -1426,6 +1426,44 @@ class MemberRepository:
)
return payment
def update_payment_reference(
self,
member_id: str,
payment_id: str,
*,
reference: str,
gnucash_transaction_id: str = "",
actor_name: str = "Vorstand",
) -> dict:
"""Relabel an existing payment without touching its date, amount, or
allocations -- used e.g. when a GnuCash import recognizes a booking as
matching an already-recorded payment and the board wants to adopt the
(better) description from the statement instead of re-importing it."""
data = self.get_contributions(member_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None,
)
if payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id and gnucash_id != str(payment.get("gnucash_transaction_id", "")):
self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
payment["reference"] = reference.strip()
if gnucash_id:
payment["gnucash_transaction_id"] = gnucash_id
payment["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_changed",
summary=f"Zahlung geändert: Referenz aktualisiert ({payment['reference']})",
actor_type="user",
actor_name=actor_name,
references={"payment_id": payment_id},
)
return payment
def delete_payment(self, member_id: str, payment_id: str) -> None:
data = self.get_contributions(member_id)
payment = next(
+97 -52
View File
@@ -22,16 +22,19 @@ from ccma.services.gnucash_import import (
list_transactions,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.monitors import centered_geometry, preferred_monitor
class GnuCashImportDialog(tk.Toplevel):
"""Lets the board point at a GnuCash file, pick one account, narrow its bookings
down with filters, and tick which ones to import as payments for this member.
down with filters, and select (multi-select: click, Ctrl+click, Shift+click for
ranges) which ones to import as payments for this member.
Only bookings with a positive amount on the selected account are offered (money
coming in), and any booking whose date + amount already match an existing payment
for this member is flagged and cannot be checked -- avoiding accidental double
imports if the same statement is imported twice."""
coming in). A booking whose date + amount already match an existing payment for
this member is flagged, but can still be selected -- at import time, the board is
asked whether to skip those or instead adopt the booking's description onto the
already-recorded payment, rather than silently blocking them."""
def __init__(
self,
@@ -50,12 +53,12 @@ class GnuCashImportDialog(tk.Toplevel):
self.accounts: list[GnuCashAccount] = []
self.account_by_label: dict[str, GnuCashAccount] = {}
self.transactions: list[GnuCashTransaction] = []
self.selected_guids: set[str] = set()
self.existing_payment_keys: set[tuple[str, str]] = set()
self.existing_payment_by_key: dict[tuple[str, str], str] = {}
self.title("Zahlungen aus GnuCash importieren")
self.transient(master.winfo_toplevel())
self.geometry("960x640")
monitor = preferred_monitor(master.winfo_toplevel())
self.geometry(centered_geometry(round(monitor.width * 0.8), round(monitor.height * 0.8), monitor))
self.minsize(780, 480)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
@@ -121,12 +124,11 @@ class GnuCashImportDialog(tk.Toplevel):
tree_frame.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(
tree_frame,
columns=("selected", "date", "description", "memo", "amount", "status"),
columns=("date", "description", "memo", "amount", "status"),
show="headings",
selectmode="browse",
selectmode="extended",
)
for key, title, width in (
("selected", "Import", 60),
("date", "Datum", 100),
("description", "Beschreibung", 280),
("memo", "Memo", 180),
@@ -134,21 +136,22 @@ class GnuCashImportDialog(tk.Toplevel):
("status", "Status", 220),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.column("selected", anchor="center", stretch=False)
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"})
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff")
self.tree.bind("<Button-1>", self._on_tree_click)
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label(
self,
text=(
"Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt "
"(eingehende Zahlungen). Rot markierte Buchungen haben Datum und Betrag einer bereits "
"vorhandenen Zahlung dieses Mitglieds und können nicht erneut importiert werden."
"(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. "
"Rot markierte Buchungen haben Datum und Betrag einer bereits vorhandenen Zahlung "
"dieses Mitglieds; beim Import kann gewählt werden, ob diese übersprungen werden "
"oder ob die Beschreibung der vorhandenen Zahlung ersetzt wird."
),
style="Mono.TLabel",
wraplength=900,
@@ -172,10 +175,10 @@ class GnuCashImportDialog(tk.Toplevel):
def _load_existing_payment_keys(self) -> None:
data = self.repository.get_contributions(self.member_id)
self.existing_payment_keys = {
(str(payment.get("date", "")), money_text(payment.get("amount", "0")))
for payment in data.payments
}
self.existing_payment_by_key = {}
for payment in data.payments:
key = (str(payment.get("date", "")), money_text(payment.get("amount", "0")))
self.existing_payment_by_key.setdefault(key, str(payment.get("payment_id", "")))
def _browse_file(self) -> None:
current_text = self.file_var.get().strip()
@@ -211,7 +214,6 @@ class GnuCashImportDialog(tk.Toplevel):
self.account_combo.configure(values=list(self.account_by_label))
if self.account_by_label and self.account_var.get() not in self.account_by_label:
self.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label)))
self.selected_guids.clear()
if self.account_var.get():
self._account_selected()
else:
@@ -255,7 +257,6 @@ class GnuCashImportDialog(tk.Toplevel):
self._render_transactions()
return
self.transactions = [item for item in all_transactions if item.amount > 0]
self.selected_guids.clear()
self._render_transactions()
file_key = self._current_file_key()
if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid:
@@ -284,22 +285,22 @@ class GnuCashImportDialog(tk.Toplevel):
result.append(item)
return result
def _duplicate_payment_id(self, item: GnuCashTransaction) -> str | None:
return self.existing_payment_by_key.get((item.date.isoformat(), money_text(item.amount)))
def _is_duplicate(self, item: GnuCashTransaction) -> bool:
return (item.date.isoformat(), money_text(item.amount)) in self.existing_payment_keys
return self._duplicate_payment_id(item) is not None
def _render_transactions(self) -> None:
previously_selected = set(self.tree.selection())
self.tree.delete(*self.tree.get_children())
for item in self._filtered_transactions():
duplicate = self._is_duplicate(item)
if duplicate:
self.selected_guids.discard(item.guid)
checked = item.guid in self.selected_guids
self.tree.insert(
"",
"end",
iid=item.guid,
values=(
"" if checked else "",
format_date_for_display(item.date.isoformat()),
item.description,
item.memo,
@@ -308,25 +309,15 @@ class GnuCashImportDialog(tk.Toplevel):
),
tags=("duplicate",) if duplicate else (),
)
still_present = [guid for guid in previously_selected if self.tree.exists(guid)]
if still_present:
self.tree.selection_set(still_present)
self._update_summary()
def _on_tree_click(self, event: tk.Event) -> None:
row_id = self.tree.identify_row(event.y)
column = self.tree.identify_column(event.x)
if not row_id or column != "#1":
return
item = next((entry for entry in self.transactions if entry.guid == row_id), None)
if item is None or self._is_duplicate(item):
return
if row_id in self.selected_guids:
self.selected_guids.discard(row_id)
else:
self.selected_guids.add(row_id)
self._render_transactions()
def _update_summary(self) -> None:
filtered = self._filtered_transactions()
selected_items = [item for item in filtered if item.guid in self.selected_guids]
selected_guids = set(self.tree.selection())
selected_items = [item for item in filtered if item.guid in selected_guids]
total = sum((item.amount for item in selected_items), Decimal("0"))
self.summary_var.set(
f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · "
@@ -334,17 +325,38 @@ class GnuCashImportDialog(tk.Toplevel):
)
def _import_selected(self) -> None:
selected_items = [
item
for item in self.transactions
if item.guid in self.selected_guids and not self._is_duplicate(item)
]
selected_guids = set(self.tree.selection())
selected_items = [item for item in self.transactions if item.guid in selected_guids]
if not selected_items:
messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self)
return
new_items = [item for item in selected_items if not self._is_duplicate(item)]
duplicate_items = [item for item in selected_items if self._is_duplicate(item)]
replace_description = False
if duplicate_items:
response = messagebox.askyesnocancel(
"Bereits vorhandene Buchungen ausgewählt",
(
f"{len(duplicate_items)} der ausgewählten Buchungen stimmen in Datum und Betrag "
"mit bereits vorhandenen Zahlungen dieses Mitglieds überein.\n\n"
"Ja: Diese werden beim Import übersprungen.\n"
"Nein: Bei diesen wird stattdessen die Beschreibung der vorhandenen Zahlung "
"durch die Beschreibung aus GnuCash ersetzt.\n"
"Abbrechen: Es wird nichts importiert."
),
parent=self,
)
if response is None:
return
replace_description = not response
imported = 0
updated = 0
processed_guids: set[str] = set()
errors: list[str] = []
for item in selected_items:
for item in new_items:
try:
self.repository.create_payment(
self.member_id,
@@ -354,21 +366,54 @@ class GnuCashImportDialog(tk.Toplevel):
gnucash_transaction_id=item.guid,
)
imported += 1
self.selected_guids.discard(item.guid)
processed_guids.add(item.guid)
except RepositoryError as exc:
errors.append(
f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}"
)
if imported:
if replace_description:
for item in duplicate_items:
payment_id = self._duplicate_payment_id(item)
if not payment_id:
continue
try:
self.repository.update_payment_reference(
self.member_id,
payment_id,
reference=item.description,
gnucash_transaction_id=item.guid,
)
updated += 1
processed_guids.add(item.guid)
except RepositoryError as exc:
errors.append(
f"{format_date_for_display(item.date.isoformat())} · "
f"{money_text(item.amount)} EUR: {exc}"
)
if imported or updated:
self._load_existing_payment_keys()
self._render_transactions()
still_selected = set(self.tree.selection()) - processed_guids
self.tree.selection_set(list(still_selected))
summary_parts = []
if imported:
summary_parts.append(f"{imported} Zahlung(en) importiert")
if updated:
summary_parts.append(f"{updated} Beschreibung(en) aktualisiert")
skipped = len(duplicate_items) if not replace_description else 0
if skipped:
summary_parts.append(f"{skipped} übersprungen (bereits vorhanden)")
summary = ", ".join(summary_parts) or "Keine Buchung verarbeitet."
if errors:
messagebox.showwarning(
"Import teilweise fehlgeschlagen",
f"{imported} Zahlung(en) importiert.\n\nNicht importiert:\n" + "\n".join(errors),
f"{summary}.\n\nFehler:\n" + "\n".join(errors),
parent=self,
)
else:
messagebox.showinfo("Import abgeschlossen", f"{imported} Zahlung(en) importiert.", parent=self)
if imported:
messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
if imported or updated:
self.on_imported()
+27 -19
View File
@@ -37,11 +37,18 @@ from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
# (they tend to hold longer label text), and only the trailing description/reference
# column stretches to take up whatever space remains.
FIXED_COLUMN_WIDTH = 90
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)
CLAIM_TABLE_COLUMNS = (
("due", "Fällig", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("title", "Forderung", 220),
("due", "Fällig", 100),
("amount", "Betrag", 90),
("status", "Status", 110),
)
@@ -127,7 +134,7 @@ class MemberTab(ttk.Frame):
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
self.pane.add(self.timeline_pane, weight=3)
self.pane.add(self.timeline_pane, weight=1)
self._build_details(self.details_pane)
self._build_timeline(self.timeline_pane)
self._pane_position_initialized = False
@@ -139,7 +146,8 @@ class MemberTab(ttk.Frame):
try:
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
if width > 1:
self.pane.sashpos(0, max(360, int(width * 0.4)))
# Chronik (timeline_pane) defaults to a third of the available width.
self.pane.sashpos(0, max(360, round(width * 2 / 3)))
self._pane_position_initialized = True
except tk.TclError:
return
@@ -283,13 +291,13 @@ class MemberTab(ttk.Frame):
row=0, column=0, sticky="w", pady=(0, 10)
)
self.claims = ttk.Treeview(
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
)
self.claim_sort_column = "due"
self.claim_sort_descending = False
for key, title, width in CLAIM_TABLE_COLUMNS:
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
self.claims.column(key, width=width, anchor="w")
self.claims.column(key, width=width, anchor="w", stretch=key == "title")
self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
@@ -307,14 +315,14 @@ class MemberTab(ttk.Frame):
selectmode="browse",
)
for key, title, width in (
("date", "Datum", 100),
("amount", "Betrag", 90),
("allocated", "Zugeordnet", 90),
("available", "Frei", 90),
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("available", "Frei", FIXED_COLUMN_WIDTH),
("reference", "Referenz", 320),
):
self.payments.heading(key, text=title)
self.payments.column(key, width=width, anchor="w")
self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
self.payments.grid(row=1, column=0, sticky="nsew")
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
@@ -347,15 +355,15 @@ class MemberTab(ttk.Frame):
selectmode="browse",
)
for key, title, width in (
("date", "Datum", 100),
("amount", "Betrag", 90),
("allocated", "Zugeordnet", 90),
("balance", "Offen", 90),
("status", "Status", 150),
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("balance", "Offen", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("reference", "Referenz / Zweck", 260),
):
self.donations.heading(key, text=title)
self.donations.column(key, width=width, anchor="w")
self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
self.donations.grid(row=1, column=0, sticky="nsew")
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
@@ -602,10 +610,10 @@ class MemberTab(ttk.Frame):
"end",
iid=claim_id,
values=(
claim.get("title", "Beitrag"),
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_total(claim)),
CLAIM_STATUS_LABELS.get(status, status.upper()),
claim.get("title", "Beitrag"),
),
)
for payment in sorted(
+4 -4
View File
@@ -597,15 +597,15 @@ class HousekeeperTab(ttk.Frame):
row=0, column=1, rowspan=2, padx=(0, 8)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2)
self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings")
self.tree = ttk.Treeview(self, columns=("severity", "due", "title", "detail"), show="headings")
for key, title, width in (
("severity", "Level", 90),
("due", "Fällig", 100),
("title", "Vorgang", 330),
("detail", "Details", 390),
("due", "Fällig", 110),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.column(key, width=width, anchor="w", stretch=key == "detail")
self.tree.grid(row=1, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._show_selected_details())
@@ -649,7 +649,7 @@ class HousekeeperTab(ttk.Frame):
"",
"end",
iid=str(index),
values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""),
values=(finding.severity.upper(), finding.due_date or "", finding.title, finding.detail),
)
def _show_selected_details(self) -> None:
+65
View File
@@ -251,6 +251,71 @@ def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
def test_payment_reference_can_be_replaced_without_touching_amount_or_allocations(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
reference="Alte Referenz",
)
updated = repository.update_payment_reference(
member.member_id,
payment["payment_id"],
reference="Neue Referenz aus GnuCash",
gnucash_transaction_id="TX-99",
)
data = repository.get_contributions(member.member_id)
assert updated["reference"] == "Neue Referenz aus GnuCash"
assert updated["gnucash_transaction_id"] == "TX-99"
assert data.payments[0]["amount"] == "10.00"
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("10.00")
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
def test_payment_reference_update_rejects_gnucash_id_already_used_elsewhere(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
gnucash_transaction_id="TX-1",
)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"claim_key": "second-claim",
"title": "Zweite Forderung",
"amount": "20.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
second_payment = repository.record_payment(
member.member_id,
"claim-2",
payment_date="2026-06-22",
amount="20.00",
allocation_amount="20.00",
)
with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"):
repository.update_payment_reference(
member.member_id,
second_payment["payment_id"],
reference="Duplikatversuch",
gnucash_transaction_id="TX-1",
)
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(