mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
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:
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user