mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
832 lines
36 KiB
Python
832 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from collections.abc import Callable
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from tkinter import messagebox, ttk
|
|
|
|
from ccma.domain.contributions import (
|
|
CLAIM_STATUS_LABELS,
|
|
allocated_total,
|
|
claim_balance,
|
|
claim_items,
|
|
claim_settled_total,
|
|
claim_status,
|
|
claim_total,
|
|
credit_allocated_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
|
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
|
from ccma.ui.labels import (
|
|
CLAIM_ITEM_TYPE_LABELS,
|
|
REMINDER_CHANNEL_LABELS,
|
|
REMINDER_STATUS_LABELS,
|
|
display_label,
|
|
storage_key,
|
|
)
|
|
|
|
|
|
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))
|
|
|
|
self._build_ledger()
|
|
|
|
footer = ttk.Frame(self)
|
|
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
|
|
footer.columnconfigure(0, weight=1)
|
|
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
|
|
self.hold_button.grid(row=0, column=0, sticky="w")
|
|
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_ledger(self) -> None:
|
|
ledger = ttk.Frame(self, padding=12)
|
|
ledger.grid(row=2, column=0, sticky="nsew")
|
|
ledger.columnconfigure(0, weight=1)
|
|
ledger.rowconfigure(0, weight=1)
|
|
self.ledger = ttk.Treeview(
|
|
ledger,
|
|
columns=("date", "description", "quantity", "unit", "amount", "status", "reference"),
|
|
show="tree headings",
|
|
)
|
|
self.ledger.heading("#0", text="Bereich / Element")
|
|
self.ledger.column("#0", width=190, minwidth=140, anchor="w")
|
|
for key, title, width in (
|
|
("date", "Datum", 120),
|
|
("description", "Beschreibung", 300),
|
|
("quantity", "Menge", 75),
|
|
("unit", "Einzelpreis", 100),
|
|
("amount", "Betrag", 105),
|
|
("status", "Status / Details", 180),
|
|
("reference", "Referenz", 210),
|
|
):
|
|
self.ledger.heading(key, text=title)
|
|
self.ledger.column(key, width=width, minwidth=60, anchor="w")
|
|
self.ledger.grid(row=0, column=0, sticky="nsew")
|
|
vertical_scroll = ttk.Scrollbar(ledger, orient="vertical", command=self.ledger.yview)
|
|
vertical_scroll.grid(row=0, column=1, sticky="ns")
|
|
horizontal_scroll = ttk.Scrollbar(ledger, orient="horizontal", command=self.ledger.xview)
|
|
horizontal_scroll.grid(row=1, column=0, sticky="ew")
|
|
self.ledger.configure(
|
|
yscrollcommand=vertical_scroll.set,
|
|
xscrollcommand=horizontal_scroll.set,
|
|
)
|
|
self.ledger.tag_configure("position-group", background="#1261a0", foreground="#ffffff")
|
|
self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff")
|
|
self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff")
|
|
self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff")
|
|
self.ledger.tag_configure("credit-group", background="#8b3d88", foreground="#ffffff")
|
|
self.ledger.tag_configure("credit", background="#5e2f5b", foreground="#ffffff")
|
|
self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff")
|
|
self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff")
|
|
self.ledger.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
|
|
|
|
buttons = ttk.Frame(ledger)
|
|
buttons.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
|
ttk.Button(buttons, text="Position hinzufügen", command=self._add_item).pack(side="left")
|
|
ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10)
|
|
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")
|
|
ttk.Button(buttons, text="Vorhandene Gutschrift zuordnen", command=self._allocate_credit).pack(
|
|
side="left", padx=(8, 8)
|
|
)
|
|
ttk.Button(buttons, text="Gutschrift erfassen", command=self._record_credit).pack(side="left")
|
|
ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10)
|
|
self.discard_reminder_button = ttk.Button(
|
|
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
|
|
)
|
|
self.discard_reminder_button.pack(side="right", padx=(8, 0))
|
|
self.send_reminder_button = ttk.Button(
|
|
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
|
|
)
|
|
self.send_reminder_button.pack(side="right", padx=(8, 0))
|
|
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
|
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
|
side="right", padx=(0, 8)
|
|
)
|
|
|
|
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 = claim_settled_total(self.data, self.claim)
|
|
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}"
|
|
)
|
|
hold = self.claim.get("dunning_hold") or {}
|
|
if hold.get("active"):
|
|
until = format_date_for_display(str(hold.get("until") or "")) or "unbefristet"
|
|
reason = str(hold.get("reason") or "ohne Begründung")
|
|
self.subtitle_var.set(f"{self.subtitle_var.get()} · MAHNSPERRE bis {until}: {reason}")
|
|
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.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
|
|
self._render_ledger()
|
|
|
|
def _render_ledger(self) -> None:
|
|
self.ledger.delete(*self.ledger.get_children())
|
|
items = claim_items(self.claim)
|
|
position_group = self.ledger.insert(
|
|
"",
|
|
"end",
|
|
iid="group:positions",
|
|
text=f"Positionen ({len(items)})",
|
|
values=("", "", "", "", f"{money_text(claim_total(self.claim))} EUR", "", ""),
|
|
tags=("position-group",),
|
|
open=True,
|
|
)
|
|
for item in items:
|
|
self.ledger.insert(
|
|
position_group,
|
|
"end",
|
|
iid=f"item:{item.get('item_id', '')}",
|
|
text=display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))),
|
|
values=(
|
|
str(item.get("created_at", ""))[:10],
|
|
item.get("description", ""),
|
|
item.get("quantity", "1"),
|
|
item.get("unit_price", item.get("amount", "")),
|
|
f"{item.get('amount', '')} EUR",
|
|
"",
|
|
"",
|
|
),
|
|
tags=("position",),
|
|
)
|
|
|
|
payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments}
|
|
allocations = [
|
|
allocation
|
|
for allocation in self.data.allocations
|
|
if str(allocation.get("claim_id", "")) == self.claim_id
|
|
]
|
|
payment_group = self.ledger.insert(
|
|
"",
|
|
"end",
|
|
iid="group:payments",
|
|
text=f"Zahlungen ({len(allocations)})",
|
|
values=("", "", "", "", f"-{money_text(allocated_total(self.data, self.claim_id))} EUR", "", ""),
|
|
tags=("payment-group",),
|
|
open=True,
|
|
)
|
|
for allocation in allocations:
|
|
payment = payment_by_id.get(str(allocation.get("payment_id", "")), {})
|
|
if not payment:
|
|
continue
|
|
payment_total = str(payment.get("amount", ""))
|
|
gnucash_id = str(payment.get("gnucash_transaction_id", ""))
|
|
self.ledger.insert(
|
|
payment_group,
|
|
"end",
|
|
iid=f"allocation:{allocation.get('allocation_id', '')}",
|
|
text="Zahlung",
|
|
values=(
|
|
format_date_for_display(str(payment.get("date", ""))),
|
|
payment.get("reference", ""),
|
|
"",
|
|
"",
|
|
f"-{allocation.get('amount', '')} EUR",
|
|
f"Zahlung gesamt: {payment_total} EUR",
|
|
f"GnuCash: {gnucash_id}" if gnucash_id else "",
|
|
),
|
|
tags=("payment",),
|
|
)
|
|
|
|
credits_by_id = {str(item.get("credit_id")): item for item in self.data.credits}
|
|
credit_allocations = [
|
|
allocation
|
|
for allocation in self.data.allocations
|
|
if str(allocation.get("claim_id", "")) == self.claim_id and str(allocation.get("credit_id", ""))
|
|
]
|
|
allocated_credit_total = money_text(
|
|
sum(
|
|
(decimal_value(item.get("amount", "0")) for item in credit_allocations),
|
|
Decimal("0"),
|
|
)
|
|
)
|
|
credit_group = self.ledger.insert(
|
|
"",
|
|
"end",
|
|
iid="group:credits",
|
|
text=f"Gutschriften ({len(credit_allocations)})",
|
|
values=(
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
f"{allocated_credit_total} EUR",
|
|
"",
|
|
"",
|
|
),
|
|
tags=("credit-group",),
|
|
open=True,
|
|
)
|
|
for allocation in credit_allocations:
|
|
credit = credits_by_id.get(str(allocation.get("credit_id", "")), {})
|
|
if not credit:
|
|
continue
|
|
credit_total = str(credit.get("amount", ""))
|
|
self.ledger.insert(
|
|
credit_group,
|
|
"end",
|
|
iid=f"credit-allocation:{allocation.get('allocation_id', '')}",
|
|
text="Gutschrift",
|
|
values=(
|
|
format_date_for_display(str(credit.get("date", ""))),
|
|
credit.get("reference", ""),
|
|
"",
|
|
"",
|
|
f"{allocation.get('amount', '')} EUR",
|
|
f"Gutschrift gesamt: {credit_total} EUR",
|
|
"",
|
|
),
|
|
tags=("credit",),
|
|
)
|
|
|
|
reminders = [
|
|
reminder
|
|
for reminder in self.data.reminders
|
|
if str(reminder.get("claim_id", "")) == self.claim_id
|
|
]
|
|
reminder_group = self.ledger.insert(
|
|
"",
|
|
"end",
|
|
iid="group:reminders",
|
|
text=f"Mahnungen ({len(reminders)})",
|
|
tags=("reminder-group",),
|
|
open=True,
|
|
)
|
|
for reminder in reminders:
|
|
status = str(reminder.get("status", "draft"))
|
|
deadline = format_date_for_display(str(reminder.get("payment_deadline") or ""))
|
|
channel = display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "")))
|
|
detail = str(reminder.get("detail", ""))
|
|
self.ledger.insert(
|
|
reminder_group,
|
|
"end",
|
|
iid=f"reminder:{reminder.get('reminder_id', '')}",
|
|
text=f"Mahnstufe {reminder.get('level', '')}",
|
|
values=(
|
|
str(reminder.get("created_at", ""))[:16],
|
|
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
|
|
"",
|
|
"",
|
|
f"{reminder.get('fee', '0.00')} EUR",
|
|
display_label(REMINDER_STATUS_LABELS, status),
|
|
" · ".join(
|
|
part
|
|
for part in (channel, f"Frist: {deadline}" if deadline else "", detail)
|
|
if part
|
|
),
|
|
),
|
|
tags=("reminder",),
|
|
)
|
|
self._update_reminder_buttons()
|
|
|
|
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 _record_credit(self) -> None:
|
|
CreditDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
self.claim_id,
|
|
claim_balance(self.data, self.claim),
|
|
self._changed,
|
|
)
|
|
|
|
def _allocate_credit(self) -> None:
|
|
AllocateCreditDialog(
|
|
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 _create_document(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
DocumentTemplateDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
self._document_generated,
|
|
claim_id=self.claim_id,
|
|
reminder_id=str(reminder["reminder_id"]) if reminder else None,
|
|
)
|
|
|
|
def _document_generated(self, _path: Path) -> None:
|
|
self._changed()
|
|
|
|
def _selected_reminder(self) -> dict | None:
|
|
selected = self.ledger.selection()
|
|
if not selected or not selected[0].startswith("reminder:"):
|
|
return None
|
|
reminder_id = selected[0].removeprefix("reminder:")
|
|
return next(
|
|
(item for item in self.data.reminders if str(item.get("reminder_id", "")) == reminder_id),
|
|
None,
|
|
)
|
|
|
|
def _update_reminder_buttons(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
|
|
state = "normal" if editable else "disabled"
|
|
self.send_reminder_button.configure(state=state)
|
|
self.discard_reminder_button.configure(state=state)
|
|
|
|
def _send_reminder(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
if not reminder or not messagebox.askyesno(
|
|
"Mahnung versandt",
|
|
"Wurde diese Mahnung tatsächlich versandt? Erst jetzt werden Frist und Gebühr gebucht.",
|
|
parent=self,
|
|
):
|
|
return
|
|
try:
|
|
self.repository.mark_reminder_sent(self.member_id, self.claim_id, str(reminder["reminder_id"]))
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Versand konnte nicht gespeichert werden", str(exc), parent=self)
|
|
return
|
|
self._changed()
|
|
|
|
def _discard_reminder(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
if not reminder or not messagebox.askyesno(
|
|
"Mahnungsentwurf verwerfen", "Diesen Mahnungsentwurf wirklich verwerfen?", parent=self
|
|
):
|
|
return
|
|
try:
|
|
self.repository.cancel_reminder(self.member_id, self.claim_id, str(reminder["reminder_id"]))
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Entwurf konnte nicht verworfen werden", str(exc), parent=self)
|
|
return
|
|
self._changed()
|
|
|
|
def _toggle_hold(self) -> None:
|
|
hold = self.claim.get("dunning_hold") or {}
|
|
if hold.get("active"):
|
|
if not messagebox.askyesno("Mahnsperre aufheben", "Mahnsperre wirklich aufheben?", parent=self):
|
|
return
|
|
try:
|
|
self.repository.set_dunning_hold(self.member_id, self.claim_id, active=False)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Mahnsperre", str(exc), parent=self)
|
|
return
|
|
self._changed()
|
|
return
|
|
DunningHoldDialog(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.resizable(False, False)
|
|
self.frame = ttk.Frame(self, padding=18)
|
|
self.frame.pack(fill="both", expand=True)
|
|
self.bind("<Escape>", lambda _event: self.destroy())
|
|
self.after_idle(self.grab_set)
|
|
|
|
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=CLAIM_ITEM_TYPE_LABELS["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=list(CLAIM_ITEM_TYPE_LABELS.values()),
|
|
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=storage_key(CLAIM_ITEM_TYPE_LABELS, 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 CreditDialog(_Dialog):
|
|
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
|
|
super().__init__(master, "Gutschrift 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),
|
|
"reference": tk.StringVar(),
|
|
}
|
|
fields = (
|
|
(f"Gutschriftsdatum ({date_input_hint()})", "date"),
|
|
("Gutschriftsbetrag", "amount"),
|
|
("Dieser Gutschrift zuordnen", "allocation"),
|
|
("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_credit(
|
|
self.member_id,
|
|
self.claim_id,
|
|
credit_date=self.variables["date"].get(),
|
|
amount=self.variables["amount"].get(),
|
|
allocation_amount=self.variables["allocation"].get(),
|
|
reference=self.variables["reference"].get(),
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Gutschrift 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 AllocateCreditDialog(_Dialog):
|
|
def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved):
|
|
super().__init__(master, "Vorhandene Gutschrift zuordnen", on_saved)
|
|
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
|
data = repository.get_contributions(member_id)
|
|
self.credit_by_label = {}
|
|
for credit in data.credits:
|
|
credit_id = str(credit.get("credit_id", ""))
|
|
available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id)
|
|
if available <= 0:
|
|
continue
|
|
label = (
|
|
f"{credit.get('date', '')} · {money_text(available)} EUR frei · "
|
|
f"{credit.get('reference', '')}"
|
|
)
|
|
self.credit_by_label[label] = (credit_id, available)
|
|
self.credit_var = tk.StringVar()
|
|
self.amount_var = tk.StringVar(value=money_text(max(-balance, Decimal("0"))))
|
|
ttk.Label(self.frame, text="Gutschrift").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
combo = ttk.Combobox(
|
|
self.frame,
|
|
textvariable=self.credit_var,
|
|
values=list(self.credit_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):
|
|
_credit_id, available = self.credit_by_label[self.credit_var.get()]
|
|
self.amount_var.set(money_text(min(available, max(-balance, Decimal("0")))))
|
|
|
|
def _save(self):
|
|
selected = self.credit_by_label.get(self.credit_var.get())
|
|
if not selected:
|
|
messagebox.showerror("Gutschrift auswählen", "Bitte eine Gutschrift auswählen.", parent=self)
|
|
return
|
|
try:
|
|
self.repository.allocate_credit(
|
|
self.member_id, self.claim_id, credit_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 vorbereiten", on_saved)
|
|
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
|
policy = repository.get_configuration().get("reminder_policy") or {}
|
|
levels = policy.get("levels") or [
|
|
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
|
|
]
|
|
self.definition_by_label = {
|
|
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
|
|
}
|
|
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
|
|
self.detail_var = tk.StringVar()
|
|
self.fee_var = tk.StringVar()
|
|
self.deadline_var = tk.StringVar()
|
|
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
|
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
level_combo = ttk.Combobox(
|
|
self.frame,
|
|
textvariable=self.level_var,
|
|
values=list(self.definition_by_label),
|
|
state="readonly",
|
|
width=38,
|
|
)
|
|
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
|
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
|
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)
|
|
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
|
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
|
)
|
|
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
|
|
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Combobox(
|
|
self.frame,
|
|
textvariable=self.channel_var,
|
|
values=list(REMINDER_CHANNEL_LABELS.values()),
|
|
state="readonly",
|
|
).grid(row=4, column=1, sticky="ew", pady=5)
|
|
self._load_definition()
|
|
self._buttons(5, self._save)
|
|
|
|
def _load_definition(self) -> None:
|
|
definition = self.definition_by_label[self.level_var.get()]
|
|
self.fee_var.set(str(definition.get("fee", "0.00")))
|
|
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
|
|
|
|
def _save(self):
|
|
definition = self.definition_by_label[self.level_var.get()]
|
|
try:
|
|
self.repository.create_reminder_draft(
|
|
self.member_id,
|
|
self.claim_id,
|
|
level=int(definition.get("level", 1)),
|
|
name=str(definition.get("name", "Mahnung")),
|
|
payment_deadline_days=int(self.deadline_var.get()),
|
|
detail=self.detail_var.get(),
|
|
fee=self.fee_var.get(),
|
|
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
|
)
|
|
except (ValueError, RepositoryError) as exc:
|
|
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
|
|
return
|
|
self.destroy()
|
|
self.on_saved()
|
|
|
|
|
|
class DunningHoldDialog(_Dialog):
|
|
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
|
super().__init__(master, "Mahnsperre setzen", on_saved)
|
|
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
|
self.reason_var = tk.StringVar()
|
|
self.until_var = tk.StringVar()
|
|
ttk.Label(self.frame, text="Grund").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Entry(self.frame, textvariable=self.reason_var, width=45).grid(row=0, column=1, pady=5)
|
|
ttk.Label(self.frame, text=f"Bis ({date_input_hint()}, optional)").grid(
|
|
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
|
|
)
|
|
ttk.Entry(self.frame, textvariable=self.until_var).grid(row=1, column=1, sticky="ew", pady=5)
|
|
self._buttons(2, self._save)
|
|
|
|
def _save(self) -> None:
|
|
try:
|
|
self.repository.set_dunning_hold(
|
|
self.member_id,
|
|
self.claim_id,
|
|
active=True,
|
|
reason=self.reason_var.get(),
|
|
until=self.until_var.get(),
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Mahnsperre konnte nicht gespeichert werden", str(exc), parent=self)
|
|
return
|
|
self.destroy()
|
|
self.on_saved()
|