From bfb65b25831cd38f488558ab21d9b50351933acb Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:51:32 +0200 Subject: [PATCH] Let individual SEPA debits be excluded from an export, not just all-or-nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SEPA export dialog always exported every pending direct debit in the filtered date range with no way to hold one back -- e.g. a member whose Rücklastschrift is still being clarified had to either be collected again regardless or the whole run skipped. The table now supports multi-select (everything selected by default) and the CSV/XML/mail exports only include the currently selected rows. Deselection survives the refresh() that _prepare() runs right before exporting (tracked by member_id, not row index, so a debit newly appearing after a requery still defaults to selected). Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/sepa_dialog.py | 72 +++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/ccma/ui/sepa_dialog.py b/src/ccma/ui/sepa_dialog.py index d13c54c..704d470 100644 --- a/src/ccma/ui/sepa_dialog.py +++ b/src/ccma/ui/sepa_dialog.py @@ -40,7 +40,7 @@ class SepaExportDialog(tk.Toplevel): def _build_ui(self) -> None: self.columnconfigure(0, weight=1) - self.rowconfigure(2, weight=1) + self.rowconfigure(3, weight=1) filters = ttk.Frame(self, padding=12) filters.grid(row=0, column=0, sticky="ew") ttk.Label(filters, text="Fällig ab").grid(row=0, column=0) @@ -57,10 +57,23 @@ class SepaExportDialog(tk.Toplevel): ) ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6) ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid( - row=1, column=0, sticky="w", padx=12, pady=(0, 8) + row=1, column=0, sticky="w", padx=12, pady=(0, 2) ) + ttk.Label( + self, + text=( + "Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick " + "abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei " + "einem späteren Einzug zu berücksichtigen." + ), + style="Muted.TLabel", + wraplength=960, + ).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8)) self.table = ttk.Treeview( - self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings" + self, + columns=("number", "name", "iban", "mandate", "purpose", "amount"), + show="headings", + selectmode="extended", ) for key, label, width, anchor in ( ("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"), @@ -69,9 +82,10 @@ class SepaExportDialog(tk.Toplevel): ): self.table.heading(key, text=label) self.table.column(key, width=width, anchor=anchor) - self.table.grid(row=2, column=0, sticky="nsew", padx=12) + self.table.grid(row=3, column=0, sticky="nsew", padx=12) + self.table.bind("<>", lambda _event: self._update_summary()) footer = ttk.Frame(self, padding=12) - footer.grid(row=3, column=0, sticky="ew") + footer.grid(row=4, column=0, sticky="ew") footer.columnconfigure(0, weight=1) ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w") ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid( @@ -107,6 +121,14 @@ class SepaExportDialog(tk.Toplevel): return due_from, due, collection def refresh(self) -> None: + # Preserve which members were deliberately deselected across a refresh (e.g. + # _prepare() re-querying right before an export) by identity, not by row index + # -- a debit newly appearing in the requeried list still defaults to selected. + previously_deselected = { + self.debits[int(iid)].member_id + for iid in self.table.get_children() + if iid not in self.table.selection() + } try: due_from, due, _collection = self._dates() self.debits, self.warnings = pending_direct_debits( @@ -116,18 +138,39 @@ class SepaExportDialog(tk.Toplevel): messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self) return self.table.delete(*self.table.get_children()) - for debit in self.debits: + for index, debit in enumerate(self.debits): self.table.insert( - "", "end", + "", "end", iid=str(index), values=(debit.member_number, debit.account_holder, debit.iban, debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"), ) - total = sum((item.amount for item in self.debits), Decimal("0")) - self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR") + self.table.selection_set( + [ + str(index) + for index, debit in enumerate(self.debits) + if debit.member_id not in previously_deselected + ] + ) + self._update_summary() self.warning_var.set( f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else "" ) + def _selected_debits(self) -> list: + return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)] + + def _update_summary(self) -> None: + selected = self._selected_debits() + total = sum((item.amount for item in self.debits), Decimal("0")) + if len(selected) == len(self.debits): + self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR") + return + selected_total = sum((item.amount for item in selected), Decimal("0")) + self.summary_var.set( + f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · " + f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)" + ) + def _prepare(self) -> tuple[date, date, date] | None: self.refresh() if not self.debits: @@ -135,6 +178,11 @@ class SepaExportDialog(tk.Toplevel): "Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self ) return None + if not self._selected_debits(): + messagebox.showinfo( + "Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self + ) + return None if self.warnings and not messagebox.askyesno( "Unvollständige Mandate", "\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?", @@ -157,7 +205,7 @@ class SepaExportDialog(tk.Toplevel): return target = self._target(".csv", "CSV-Datei", dates[2]) if target: - write_csv(target, self.debits, collection_date=dates[2]) + write_csv(target, self._selected_debits(), collection_date=dates[2]) messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self) def _save_xml(self) -> None: @@ -169,7 +217,7 @@ class SepaExportDialog(tk.Toplevel): return try: write_pain008( - target, self.debits, collection_date=dates[2], + target, self._selected_debits(), collection_date=dates[2], organization=self.repository.get_configuration().get("organization") or {}, ) except RepositoryError as exc: @@ -180,7 +228,7 @@ class SepaExportDialog(tk.Toplevel): def _open_mail_export(self) -> None: dates = self._prepare() if dates: - SepaMailExportDialog(self, self.repository, self.debits, dates[2]) + SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2]) class SepaMailExportDialog(tk.Toplevel):