Files
CCMA/src/ccma/ui/payment_dialog.py
T
Marcel PeterkauandClaude Sonnet 5 4906db3d55 Prefill the allocation amount when clicking an unallocated claim/donation
Clicking a claim or donation row in the payment allocation table used to
just show "0.00" if nothing was allocated to it yet, requiring the board
to type the amount by hand every time. It now suggests whichever is
smaller: what that claim/donation still has open, or what's left unused
of the payment -- so the common case is click the row, then Zuordnung
setzen, done. Rows that already have an allocation still show their
current value instead, so editing one isn't overwritten by the
suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:02:37 +02:00

531 lines
22 KiB
Python

from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal, InvalidOperation
from tkinter import messagebox, ttk
from ccma.domain.contributions import (
claim_balance,
claim_status,
decimal_value,
donation_balance,
money_text,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.donation_dialog import DonationEditDialog
class _AllocationTable:
"""Embeds a combined claims + donations allocation tree into a payment dialog.
Only claims/donations that are still open (or already linked to this payment) are
listed, so fully-settled ones don't clutter the picture. A row's visibility is
decided once, from a snapshot taken when the dialog opens (or when a new donation
is created inline) -- it never disappears again for the rest of the editing
session just because the user unassigned it. That way "Zuordnung lösen" followed
by "Zuordnung setzen" always works on the same row instead of the target vanishing.
Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in
place, so the owning dialog can read them back at save time. Nothing is written to
the repository until the dialog's own Save button does so.
"""
def __init__(
self,
parent: tk.Misc,
repository: MemberRepository,
member_id: str,
*,
claim_allocations: dict[str, str],
donation_allocations: dict[str, str],
on_change: Callable[[], None],
get_payment_amount: Callable[[], Decimal],
):
self.repository = repository
self.member_id = member_id
self.claim_allocations = claim_allocations
self.donation_allocations = donation_allocations
self.on_change = on_change
self.get_payment_amount = get_payment_amount
# Fixed snapshot of what this payment already covered when the dialog opened.
# Capacities are computed against this baseline rather than the live, editable
# dicts above -- otherwise "Maximal zuordenbar" would inflate every time the
# user changes an amount, since claim_balance() reflects only persisted state.
self._baseline_claim_allocations = dict(claim_allocations)
self._baseline_donation_allocations = dict(donation_allocations)
self._visible_claim_ids: set[str] = set(claim_allocations)
self._visible_donation_ids: set[str] = set(donation_allocations)
self._load_targets()
self._build(parent)
self._refresh()
def _load_targets(self) -> None:
self.data = self.repository.get_contributions(self.member_id)
self._visible_claim_ids |= {
str(claim.get("claim_id", ""))
for claim in self.data.claims
if str(claim.get("claim_id", ""))
and claim_status(self.data, claim) != "cancelled"
and claim_balance(self.data, claim) > 0
}
self._visible_donation_ids |= {
str(item.get("donation_id", ""))
for item in self.data.donations
if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0
}
self.claims_by_id = {
str(claim.get("claim_id", "")): claim
for claim in self.data.claims
if str(claim.get("claim_id", "")) in self._visible_claim_ids
}
self.donations_by_id = {
str(item.get("donation_id", "")): item
for item in self.data.donations
if str(item.get("donation_id", "")) in self._visible_donation_ids
}
def _capacities(self) -> None:
self.claim_capacity: dict[str, Decimal] = {}
for claim_id, claim in self.claims_by_id.items():
baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0"))
self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0"))
self.donation_capacity: dict[str, Decimal] = {}
for donation_id, donation in self.donations_by_id.items():
baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0"))
self.donation_capacity[donation_id] = max(
donation_balance(self.data, donation) + baseline, Decimal("0")
)
def _build(self, parent: tk.Misc) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(
parent,
columns=("kind", "title", "date", "capacity", "allocated"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("kind", "Typ", 90),
("title", "Forderung / Spende", 260),
("date", "Fällig / Datum", 110),
("capacity", "Maximal zuordenbar", 140),
("allocated", "Aktuell zugeordnet", 140),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.bind("<<TreeviewSelect>>", self._select)
actions = ttk.Frame(parent)
actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
ttk.Label(actions, text="Betrag für Auswahl").pack(side="left")
self.amount_var = tk.StringVar()
ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8))
ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left")
ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0))
ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left")
def _refresh(self) -> None:
selected = self.tree.selection()
self.tree.delete(*self.tree.get_children())
self._capacities()
for claim_id, claim in sorted(
self.claims_by_id.items(),
key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()),
):
self.tree.insert(
"",
"end",
iid=f"claim:{claim_id}",
values=(
"Forderung",
claim.get("title", "Forderung"),
format_date_for_display(str(claim.get("due_date", ""))),
f"{money_text(self.claim_capacity[claim_id])} EUR",
f"{self.claim_allocations.get(claim_id, '0.00')} EUR",
),
)
for donation_id, donation in sorted(
self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True
):
label = donation.get("reference") or donation.get("purpose") or "Spende"
self.tree.insert(
"",
"end",
iid=f"donation:{donation_id}",
values=(
"Spende",
label,
format_date_for_display(str(donation.get("date", ""))),
f"{money_text(self.donation_capacity[donation_id])} EUR",
f"{self.donation_allocations.get(donation_id, '0.00')} EUR",
),
)
if selected and self.tree.exists(selected[0]):
self.tree.selection_set(selected[0])
self.on_change()
@staticmethod
def _target(iid: str) -> tuple[str, str]:
kind, target_id = iid.split(":", 1)
return kind, target_id
def _select(self, _event: tk.Event | None = None) -> None:
selected = self.tree.selection()
if not selected:
return
kind, target_id = self._target(selected[0])
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
current = allocations.get(target_id)
if current:
# Already allocated: show the existing amount so it can be reviewed/edited.
self.amount_var.set(current)
return
# Not yet allocated: suggest whichever is smaller -- what this claim/donation
# still needs, or what's left of the payment -- so the common case is just
# "click the row, then Zuordnung setzen".
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
free = max(self._payment_amount() - self.total_allocated(), Decimal("0"))
self.amount_var.set(money_text(min(capacity, free)))
def _payment_amount(self) -> Decimal:
try:
return self.get_payment_amount()
except ValueError:
return Decimal("0")
def _set(self) -> None:
selected = self.tree.selection()
if not selected:
messagebox.showerror(
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
)
return
kind, target_id = self._target(selected[0])
try:
value = decimal_value(self.amount_var.get(), "Zuordnung")
except ValueError as exc:
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree)
return
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
if value < 0 or value > capacity:
messagebox.showerror(
"Ungültige Zuordnung",
f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.",
parent=self.tree,
)
return
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
if value:
allocations[target_id] = money_text(value)
else:
allocations.pop(target_id, None)
self._refresh()
def _remove(self) -> None:
selected = self.tree.selection()
if not selected:
messagebox.showerror(
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
)
return
kind, target_id = self._target(selected[0])
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
allocations.pop(target_id, None)
self.amount_var.set("0.00")
self._refresh()
def _create_donation(self) -> None:
DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created)
def _donation_created(self) -> None:
self._load_targets()
self._refresh()
def total_allocated(self) -> Decimal:
return sum(
(
decimal_value(value)
for value in (*self.claim_allocations.values(), *self.donation_allocations.values())
),
Decimal("0"),
)
class PaymentCreateDialog(tk.Toplevel):
"""Records a payment and lets the board allocate parts of it to open claims and/or
donations right away — including creating a new donation on the fly — instead of
having to save the bare payment first and assign it in a separate step."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_saved = on_saved
self.claim_allocations: dict[str, str] = {}
self.donation_allocations: dict[str, str] = {}
self.allocation_table: _AllocationTable | None = None
self.title("Zahlung anlegen")
self.transient(master.winfo_toplevel())
self.geometry("880x560")
self.minsize(720, 460)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self.after_idle(self._activate)
def _activate(self) -> None:
try:
self.deiconify()
self.lift()
self.focus_force()
self.grab_set()
except tk.TclError:
return
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
form = ttk.Frame(self, padding=16)
form.grid(row=0, column=0, sticky="ew")
form.columnconfigure(1, weight=1)
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(),
"gnucash": tk.StringVar(),
"reference": tk.StringVar(),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("GnuCash-ID (optional)", "gnucash"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
row=row, column=1, sticky="ew", pady=4
)
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
self.total_var = tk.StringVar()
allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
self.allocation_table = _AllocationTable(
allocation_frame,
self.repository,
self.member_id,
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
on_change=self._refresh_totals,
get_payment_amount=self._current_payment_amount,
)
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
)
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=3, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons, text="Speichern", style="Accent.TButton", command=self._save
).pack(side="left")
self._refresh_totals()
def _current_payment_amount(self) -> Decimal:
return decimal_value(self.variables["amount"].get())
def _refresh_totals(self) -> None:
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
try:
payment_amount = self._current_payment_amount()
free = payment_amount - allocated
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR")
except (ValueError, InvalidOperation):
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
def _save(self) -> None:
try:
self.repository.create_payment(
self.member_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class PaymentEditDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
payment_id: str,
on_saved: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.payment_id = payment_id
self.on_saved = on_saved
self.data = repository.get_contributions(member_id)
self.payment = next(
(
payment
for payment in self.data.payments
if str(payment.get("payment_id", "")) == payment_id
),
None,
)
if self.payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
self.claim_allocations, self.donation_allocations = self._current_allocations()
self.allocation_table: _AllocationTable | None = None
self.title("Zahlung bearbeiten")
self.transient(master.winfo_toplevel())
self.geometry("880x560")
self.minsize(720, 460)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self.after_idle(self._activate)
def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]:
claim_totals: dict[str, Decimal] = {}
donation_totals: dict[str, Decimal] = {}
for allocation in self.data.allocations:
if str(allocation.get("payment_id", "")) != self.payment_id:
continue
amount = decimal_value(allocation.get("amount", "0"))
donation_id = str(allocation.get("donation_id", ""))
if donation_id:
donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount
continue
claim_id = str(allocation.get("claim_id", ""))
if claim_id:
claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount
return (
{claim_id: money_text(value) for claim_id, value in claim_totals.items()},
{donation_id: money_text(value) for donation_id, value in donation_totals.items()},
)
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
form = ttk.Frame(self, padding=16)
form.grid(row=0, column=0, sticky="ew")
form.columnconfigure(1, weight=1)
self.variables = {
"date": tk.StringVar(
value=format_date_for_display(str(self.payment.get("date", "")))
),
"amount": tk.StringVar(value=str(self.payment.get("amount", ""))),
"gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))),
"reference": tk.StringVar(value=str(self.payment.get("reference", ""))),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("GnuCash-ID", "gnucash"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
row=row, column=1, sticky="ew", pady=4
)
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
self.total_var = tk.StringVar()
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
self.allocation_table = _AllocationTable(
allocation_frame,
self.repository,
self.member_id,
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
on_change=self._refresh_totals,
get_payment_amount=self._current_payment_amount,
)
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
)
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=3, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
text="Zahlung speichern",
style="Accent.TButton",
command=self._save,
).pack(side="left")
self._refresh_totals()
def _activate(self) -> None:
try:
self.deiconify()
self.lift()
self.focus_force()
self.grab_set()
except tk.TclError:
return
def _current_payment_amount(self) -> Decimal:
return decimal_value(self.variables["amount"].get())
def _refresh_totals(self) -> None:
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
try:
payment_amount = self._current_payment_amount()
free = payment_amount - allocated
self.total_var.set(
f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
)
except (ValueError, InvalidOperation):
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
def _save(self) -> None:
try:
self.repository.update_payment(
self.member_id,
self.payment_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()