mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add itemized claims and payments
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_items,
|
||||
claim_status,
|
||||
claim_total,
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
class ClaimTab(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
on_close: Callable[[], None],
|
||||
on_changed: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_id = claim_id
|
||||
self.on_close = on_close
|
||||
self.on_changed = on_changed
|
||||
self._build_ui()
|
||||
self.refresh()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
header = ttk.Frame(self)
|
||||
header.grid(row=0, column=0, sticky="ew", pady=(0, 12))
|
||||
header.columnconfigure(0, weight=1)
|
||||
self.title_var = tk.StringVar()
|
||||
self.subtitle_var = tk.StringVar()
|
||||
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
ttk.Label(header, textvariable=self.subtitle_var, style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w", pady=(3, 0)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
||||
|
||||
summary = ttk.Frame(self)
|
||||
summary.grid(row=1, column=0, sticky="ew", pady=(0, 12))
|
||||
self.summary_vars: dict[str, tk.StringVar] = {}
|
||||
for column, (key, title) in enumerate(
|
||||
(("total", "GESAMT"), ("paid", "BEZAHLT"), ("balance", "OFFEN"), ("status", "STATUS"))
|
||||
):
|
||||
card = ttk.Frame(summary, style="Card.TFrame", padding=12)
|
||||
card.grid(row=0, column=column, sticky="nsew", padx=(0, 8))
|
||||
summary.columnconfigure(column, weight=1)
|
||||
ttk.Label(card, text=title, style="CardTitle.TLabel").pack(anchor="w")
|
||||
variable = tk.StringVar()
|
||||
self.summary_vars[key] = variable
|
||||
ttk.Label(card, textvariable=variable, style="CardValue.TLabel").pack(anchor="w", pady=(5, 0))
|
||||
|
||||
notebook = ttk.Notebook(self)
|
||||
notebook.grid(row=2, column=0, sticky="nsew")
|
||||
positions = ttk.Frame(notebook, padding=12)
|
||||
payments = ttk.Frame(notebook, padding=12)
|
||||
reminders = ttk.Frame(notebook, padding=12)
|
||||
notebook.add(positions, text="Positionen")
|
||||
notebook.add(payments, text="Zahlungen")
|
||||
notebook.add(reminders, text="Mahnungen")
|
||||
self._build_positions(positions)
|
||||
self._build_payments(payments)
|
||||
self._build_reminders(reminders)
|
||||
|
||||
footer = ttk.Frame(self)
|
||||
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
||||
self.cancel_button.grid(row=0, column=1, sticky="e")
|
||||
|
||||
def _build_positions(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(0, weight=1)
|
||||
self.positions = ttk.Treeview(
|
||||
parent, columns=("type", "description", "quantity", "unit", "amount"), show="headings"
|
||||
)
|
||||
for key, title, width in (
|
||||
("type", "Typ", 100),
|
||||
("description", "Beschreibung", 360),
|
||||
("quantity", "Menge", 80),
|
||||
("unit", "Einzelpreis", 100),
|
||||
("amount", "Betrag", 100),
|
||||
):
|
||||
self.positions.heading(key, text=title)
|
||||
self.positions.column(key, width=width, anchor="w")
|
||||
self.positions.grid(row=0, column=0, sticky="nsew")
|
||||
ttk.Button(parent, text="Position hinzufügen", command=self._add_item).grid(
|
||||
row=1, column=0, sticky="e", pady=(10, 0)
|
||||
)
|
||||
|
||||
def _build_payments(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(0, weight=1)
|
||||
self.payments = ttk.Treeview(
|
||||
parent, columns=("date", "allocated", "payment", "gnucash", "reference"), show="headings"
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", 110),
|
||||
("allocated", "Zugeordnet", 100),
|
||||
("payment", "Zahlung gesamt", 110),
|
||||
("gnucash", "GnuCash-ID", 180),
|
||||
("reference", "Referenz", 280),
|
||||
):
|
||||
self.payments.heading(key, text=title)
|
||||
self.payments.column(key, width=width, anchor="w")
|
||||
self.payments.grid(row=0, column=0, sticky="nsew")
|
||||
buttons = ttk.Frame(parent)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(10, 0))
|
||||
ttk.Button(buttons, text="Vorhandene Zahlung zuordnen", command=self._allocate_payment).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left")
|
||||
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(0, weight=1)
|
||||
self.reminders = ttk.Treeview(parent, columns=("created", "level", "detail", "fee"), show="headings")
|
||||
for key, title, width in (
|
||||
("created", "Erstellt", 150),
|
||||
("level", "Stufe", 80),
|
||||
("detail", "Details", 430),
|
||||
("fee", "Gebühr", 100),
|
||||
):
|
||||
self.reminders.heading(key, text=title)
|
||||
self.reminders.column(key, width=width, anchor="w")
|
||||
self.reminders.grid(row=0, column=0, sticky="nsew")
|
||||
ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid(
|
||||
row=1, column=0, sticky="e", pady=(10, 0)
|
||||
)
|
||||
|
||||
def refresh(self) -> None:
|
||||
try:
|
||||
self.data, self.claim = self.repository.get_claim(self.member_id, self.claim_id)
|
||||
member = self.repository.get_member(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self)
|
||||
return
|
||||
total = claim_total(self.claim)
|
||||
paid = allocated_total(self.data, self.claim_id)
|
||||
balance = claim_balance(self.data, self.claim)
|
||||
status = claim_status(self.data, self.claim)
|
||||
self.title_var.set(str(self.claim.get("title") or "Forderung"))
|
||||
due = format_date_for_display(str(self.claim.get("due_date", "")))
|
||||
self.subtitle_var.set(
|
||||
f"{member.member_number} · {member.display_name} · Fällig: {due or '—'} · {self.claim_id}"
|
||||
)
|
||||
self.summary_vars["total"].set(f"{money_text(total)} EUR")
|
||||
self.summary_vars["paid"].set(f"{money_text(paid)} EUR")
|
||||
self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
|
||||
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
|
||||
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
|
||||
self._render_positions()
|
||||
self._render_payments()
|
||||
self._render_reminders()
|
||||
|
||||
def _render_positions(self) -> None:
|
||||
self.positions.delete(*self.positions.get_children())
|
||||
for item in claim_items(self.claim):
|
||||
self.positions.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(item.get("item_id", "")),
|
||||
values=(
|
||||
item.get("type", ""),
|
||||
item.get("description", ""),
|
||||
item.get("quantity", "1"),
|
||||
item.get("unit_price", item.get("amount", "")),
|
||||
item.get("amount", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _render_payments(self) -> None:
|
||||
self.payments.delete(*self.payments.get_children())
|
||||
payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments}
|
||||
for allocation in self.data.allocations:
|
||||
if str(allocation.get("claim_id", "")) != self.claim_id:
|
||||
continue
|
||||
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
|
||||
self.payments.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(allocation.get("allocation_id", "")),
|
||||
values=(
|
||||
format_date_for_display(str(payment.get("date", ""))),
|
||||
allocation.get("amount", ""),
|
||||
payment.get("amount", ""),
|
||||
payment.get("gnucash_transaction_id", ""),
|
||||
payment.get("reference", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _render_reminders(self) -> None:
|
||||
self.reminders.delete(*self.reminders.get_children())
|
||||
items = {str(item.get("item_id")): item for item in claim_items(self.claim)}
|
||||
for reminder in self.data.reminders:
|
||||
if str(reminder.get("claim_id", "")) != self.claim_id:
|
||||
continue
|
||||
fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
|
||||
self.reminders.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(reminder.get("reminder_id", "")),
|
||||
values=(
|
||||
str(reminder.get("created_at", ""))[:16],
|
||||
reminder.get("level", ""),
|
||||
reminder.get("detail", ""),
|
||||
fee_item.get("amount", "0.00"),
|
||||
),
|
||||
)
|
||||
|
||||
def _add_item(self) -> None:
|
||||
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
|
||||
def _record_payment(self) -> None:
|
||||
PaymentDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
claim_balance(self.data, self.claim),
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _allocate_payment(self) -> None:
|
||||
AllocatePaymentDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
claim_balance(self.data, self.claim),
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _add_reminder(self) -> None:
|
||||
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
|
||||
def _cancel_claim(self) -> None:
|
||||
if not messagebox.askyesno(
|
||||
"Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.cancel_claim(self.member_id, self.claim_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Storno fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
|
||||
class _Dialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
|
||||
super().__init__(master)
|
||||
self.on_saved = on_saved
|
||||
self.title(title)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
self.resizable(False, False)
|
||||
self.frame = ttk.Frame(self, padding=18)
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
|
||||
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||
buttons = ttk.Frame(self.frame)
|
||||
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class ItemDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Forderungsposition hinzufügen", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
self.type_var = tk.StringVar(value="correction")
|
||||
self.description_var = tk.StringVar()
|
||||
self.quantity_var = tk.StringVar(value="1")
|
||||
self.unit_var = tk.StringVar()
|
||||
fields = (
|
||||
("Typ", self.type_var),
|
||||
("Beschreibung", self.description_var),
|
||||
("Menge", self.quantity_var),
|
||||
("Einzelpreis", self.unit_var),
|
||||
)
|
||||
for row, (label, variable) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
if row == 0:
|
||||
ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=variable,
|
||||
values=("base", "product", "service", "fee", "discount", "credit", "correction"),
|
||||
state="readonly",
|
||||
width=32,
|
||||
).grid(row=row, column=1, sticky="ew", pady=5)
|
||||
else:
|
||||
ttk.Entry(self.frame, textvariable=variable, width=35).grid(row=row, column=1, pady=5)
|
||||
self._buttons(len(fields), self._save)
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
self.repository.add_claim_item(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
description=self.description_var.get(),
|
||||
quantity=self.quantity_var.get(),
|
||||
unit_price=self.unit_var.get(),
|
||||
item_type=self.type_var.get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Position konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class PaymentDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
|
||||
super().__init__(master, "Zahlung erfassen", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
initial = money_text(max(balance, Decimal("0")))
|
||||
self.variables = {
|
||||
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
|
||||
"amount": tk.StringVar(value=initial),
|
||||
"allocation": tk.StringVar(value=initial),
|
||||
"gnucash": tk.StringVar(),
|
||||
"reference": tk.StringVar(),
|
||||
}
|
||||
fields = (
|
||||
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||
("Zahlungsbetrag", "amount"),
|
||||
("Dieser Forderung zuordnen", "allocation"),
|
||||
("GnuCash-ID (optional)", "gnucash"),
|
||||
("Referenz", "reference"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5)
|
||||
self._buttons(len(fields), self._save)
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
self.repository.record_payment(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
allocation_amount=self.variables["allocation"].get(),
|
||||
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 AllocatePaymentDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
|
||||
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
data = repository.get_contributions(member_id)
|
||||
self.payment_by_label = {}
|
||||
for payment in data.payments:
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
||||
if available <= 0:
|
||||
continue
|
||||
label = (
|
||||
f"{payment.get('date', '')} · {money_text(available)} EUR frei · "
|
||||
f"{payment.get('reference', '')}"
|
||||
)
|
||||
self.payment_by_label[label] = (payment_id, available)
|
||||
self.payment_var = tk.StringVar()
|
||||
self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0"))))
|
||||
ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.payment_var,
|
||||
values=list(self.payment_by_label),
|
||||
state="readonly",
|
||||
width=60,
|
||||
)
|
||||
combo.grid(row=0, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
||||
self._buttons(2, self._save)
|
||||
|
||||
def _select(self, balance):
|
||||
_payment_id, available = self.payment_by_label[self.payment_var.get()]
|
||||
self.amount_var.set(money_text(min(available, max(balance, Decimal("0")))))
|
||||
|
||||
def _save(self):
|
||||
selected = self.payment_by_label.get(self.payment_var.get())
|
||||
if not selected:
|
||||
messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
self.repository.allocate_payment(
|
||||
self.member_id, self.claim_id, payment_id=selected[0], amount=self.amount_var.get()
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung erfassen", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
self.level_var = tk.IntVar(value=1)
|
||||
self.detail_var = tk.StringVar()
|
||||
self.fee_var = tk.StringVar(value="0.00")
|
||||
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid(
|
||||
row=0, column=1, sticky="w", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
self._buttons(3, self._save)
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
self.repository.add_reminder(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=self.level_var.get(),
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
)
|
||||
except (tk.TclError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
Reference in New Issue
Block a user