mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
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>
531 lines
22 KiB
Python
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()
|