mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 19:35:30 +02:00
264 lines
10 KiB
Python
264 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from collections.abc import Callable
|
|
from decimal import Decimal, InvalidOperation
|
|
from tkinter import messagebox, ttk
|
|
|
|
from ccma.domain.contributions import (
|
|
claim_balance,
|
|
claim_status,
|
|
decimal_value,
|
|
money_text,
|
|
)
|
|
from ccma.domain.dates import date_input_hint, format_date_for_display
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
|
|
|
|
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.allocations = self._current_allocations()
|
|
self.claims_by_id = {
|
|
str(claim.get("claim_id", "")): claim
|
|
for claim in self.data.claims
|
|
if str(claim.get("claim_id", ""))
|
|
and (
|
|
claim_status(self.data, claim) != "cancelled"
|
|
or str(claim.get("claim_id", "")) in self.allocations
|
|
)
|
|
}
|
|
self.capacities = self._claim_capacities()
|
|
|
|
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._refresh_claims()
|
|
self.after_idle(self._activate)
|
|
|
|
def _current_allocations(self) -> dict[str, str]:
|
|
totals: dict[str, Decimal] = {}
|
|
for allocation in self.data.allocations:
|
|
if str(allocation.get("payment_id", "")) != self.payment_id:
|
|
continue
|
|
claim_id = str(allocation.get("claim_id", ""))
|
|
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
|
|
allocation.get("amount", "0")
|
|
)
|
|
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
|
|
|
|
def _claim_capacities(self) -> dict[str, Decimal]:
|
|
capacities = {}
|
|
for claim_id, claim in self.claims_by_id.items():
|
|
current = decimal_value(self.allocations.get(claim_id, "0"))
|
|
capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0"))
|
|
return capacities
|
|
|
|
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())
|
|
|
|
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
|
|
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
|
allocation_frame.columnconfigure(0, weight=1)
|
|
allocation_frame.rowconfigure(0, weight=1)
|
|
self.claims = ttk.Treeview(
|
|
allocation_frame,
|
|
columns=("title", "due", "available", "allocated"),
|
|
show="headings",
|
|
selectmode="browse",
|
|
)
|
|
for key, title, width in (
|
|
("title", "Forderung", 300),
|
|
("due", "Fällig", 110),
|
|
("available", "Maximal zuordenbar", 150),
|
|
("allocated", "Aktuell zugeordnet", 150),
|
|
):
|
|
self.claims.heading(key, text=title)
|
|
self.claims.column(key, width=width, anchor="w")
|
|
self.claims.grid(row=0, column=0, sticky="nsew")
|
|
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
|
|
scrollbar.grid(row=0, column=1, sticky="ns")
|
|
self.claims.configure(yscrollcommand=scrollbar.set)
|
|
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
|
|
|
|
allocation_actions = ttk.Frame(allocation_frame)
|
|
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
|
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
|
|
self.allocation_var = tk.StringVar()
|
|
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
|
|
side="left", padx=(8, 8)
|
|
)
|
|
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
|
|
side="left", padx=(8, 0)
|
|
)
|
|
self.total_var = tk.StringVar()
|
|
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
|
|
side="right"
|
|
)
|
|
|
|
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
|
buttons.grid(row=2, 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")
|
|
|
|
def _activate(self) -> None:
|
|
try:
|
|
self.deiconify()
|
|
self.lift()
|
|
self.focus_force()
|
|
self.grab_set()
|
|
except tk.TclError:
|
|
return
|
|
|
|
def _refresh_claims(self) -> None:
|
|
selected = self.claims.selection()
|
|
self.claims.delete(*self.claims.get_children())
|
|
ordered = sorted(
|
|
self.claims_by_id.items(),
|
|
key=lambda item: (
|
|
str(item[1].get("due_date", "")),
|
|
str(item[1].get("title", "")).casefold(),
|
|
),
|
|
)
|
|
for claim_id, claim in ordered:
|
|
self.claims.insert(
|
|
"",
|
|
"end",
|
|
iid=claim_id,
|
|
values=(
|
|
claim.get("title", "Forderung"),
|
|
format_date_for_display(str(claim.get("due_date", ""))),
|
|
f"{money_text(self.capacities[claim_id])} EUR",
|
|
f"{self.allocations.get(claim_id, '0.00')} EUR",
|
|
),
|
|
)
|
|
if selected and self.claims.exists(selected[0]):
|
|
self.claims.selection_set(selected[0])
|
|
self._refresh_totals()
|
|
|
|
def _select_claim(self, _event=None) -> None:
|
|
selected = self.claims.selection()
|
|
if selected:
|
|
self.allocation_var.set(self.allocations.get(selected[0], "0.00"))
|
|
|
|
def _set_allocation(self) -> None:
|
|
selected = self.claims.selection()
|
|
if not selected:
|
|
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
|
return
|
|
try:
|
|
amount = decimal_value(self.allocation_var.get(), "Zuordnung")
|
|
except ValueError as exc:
|
|
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self)
|
|
return
|
|
capacity = self.capacities[selected[0]]
|
|
if amount < 0 or amount > capacity:
|
|
messagebox.showerror(
|
|
"Ungültige Zuordnung",
|
|
f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.",
|
|
parent=self,
|
|
)
|
|
return
|
|
if amount:
|
|
self.allocations[selected[0]] = money_text(amount)
|
|
else:
|
|
self.allocations.pop(selected[0], None)
|
|
self._refresh_claims()
|
|
|
|
def _remove_allocation(self) -> None:
|
|
selected = self.claims.selection()
|
|
if not selected:
|
|
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
|
return
|
|
self.allocations.pop(selected[0], None)
|
|
self.allocation_var.set("0.00")
|
|
self._refresh_claims()
|
|
|
|
def _refresh_totals(self) -> None:
|
|
allocated = sum(
|
|
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
|
|
)
|
|
try:
|
|
payment_amount = decimal_value(self.variables["amount"].get())
|
|
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.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()
|