mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
A Rücklastschrift is still the member's own failure to ensure cover or provide valid bank details, so it belongs in the same escalation ladder as a regular reminder -- it now occupies whatever level the claim is next due for, alongside a "Rücklastschrift" preset next to the existing Mahnstufe presets. Mahnung vorbereiten no longer lets the board pick a level (it's computed automatically from what's already been sent, since the sequencing was already server-enforced) and replaces the single fee field with a small items table (Beschreibung + Betrag), so a reminder can carry several charges at once -- e.g. Rücklastschriftgebühr plus Bankgebühr plus Porto -- each landing on the claim as its own line item instead of one lump sum under a generic label. The description field offers the configured standard texts but stays free-editable. Those standard texts and amounts, plus the dunning levels themselves (name/fee/deadline), are now configurable from Optionen -> Mahnungen instead of only being editable by hand-editing repository.json, which was the case for the whole reminder policy until now. Cleaned up reminder_fee/failed_debit_fee on contribution rules while at it -- both were unused leftovers superseded by this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1188 lines
50 KiB
Python
1188 lines
50 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 filedialog, 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,
|
|
reminder_items_total,
|
|
)
|
|
from ccma.domain.dates import date_input_hint, format_date_for_display
|
|
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
|
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.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
|
|
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
|
|
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
|
self.cancel_button.grid(row=0, column=2, sticky="e", padx=(0, 8))
|
|
self.delete_button = ttk.Button(footer, text="Forderung löschen", command=self._delete_claim)
|
|
self.delete_button.grid(row=0, column=3, 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))
|
|
self.email_reminder_button = ttk.Button(
|
|
buttons,
|
|
text="E-Mail erzeugen & versandt",
|
|
command=self._email_reminder,
|
|
state="disabled",
|
|
)
|
|
self.email_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.edit_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"{money_text(reminder_items_total(reminder))} 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.email_reminder_button.configure(state=state)
|
|
self.discard_reminder_button.configure(state=state)
|
|
|
|
def _email_reminder(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
if reminder:
|
|
ReminderMailDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
self.claim_id,
|
|
str(reminder["reminder_id"]),
|
|
str(reminder.get("name", "Mahnung")),
|
|
self._changed,
|
|
)
|
|
|
|
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 _delete_claim(self) -> None:
|
|
allocated = allocated_total(self.data, self.claim_id)
|
|
detail = "Diese Forderung wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
|
if allocated:
|
|
detail += (
|
|
f"\n\nZugeordnete Zahlungen/Gutschriften in Höhe von {money_text(allocated)} EUR "
|
|
"werden dabei gelöst und stehen danach wieder frei zur Verfügung."
|
|
)
|
|
if not messagebox.askyesno("Forderung löschen", detail, parent=self):
|
|
return
|
|
try:
|
|
self.repository.delete_claim(self.member_id, self.claim_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
self.on_changed()
|
|
self.on_close()
|
|
|
|
def _edit_claim(self) -> None:
|
|
ClaimEditDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
self.claim_id,
|
|
self.claim,
|
|
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 ClaimEditDialog(_Dialog):
|
|
def __init__(self, master, repository, member_id, claim_id, claim, on_saved):
|
|
super().__init__(master, "Forderung bearbeiten", on_saved)
|
|
self.repository = repository
|
|
self.member_id = member_id
|
|
self.claim_id = claim_id
|
|
items = claim_items(claim)
|
|
base_item = next(
|
|
(item for item in items if str(item.get("type", "")) == "base"),
|
|
items[0] if items else {},
|
|
)
|
|
self.variables = {
|
|
"title": tk.StringVar(value=str(claim.get("title", ""))),
|
|
"due_date": tk.StringVar(
|
|
value=format_date_for_display(str(claim.get("due_date", "")))
|
|
),
|
|
"base_amount": tk.StringVar(
|
|
value=str(base_item.get("amount", claim.get("amount", "")))
|
|
),
|
|
"description": tk.StringVar(value=str(base_item.get("description", ""))),
|
|
}
|
|
fields = (
|
|
("Titel", "title"),
|
|
(f"Fällig am ({date_input_hint()})", "due_date"),
|
|
("Grundbetrag", "base_amount"),
|
|
("Beschreibung der Grundposition", "description"),
|
|
)
|
|
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=52).grid(
|
|
row=row, column=1, sticky="ew", pady=5
|
|
)
|
|
ttk.Label(
|
|
self.frame,
|
|
text=(
|
|
"Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, "
|
|
"zum Beispiel Mahngebühren, bleiben erhalten."
|
|
),
|
|
style="Mono.TLabel",
|
|
wraplength=520,
|
|
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
|
|
self._buttons(len(fields) + 1, self._save)
|
|
|
|
def _save(self) -> None:
|
|
try:
|
|
self.repository.update_claim(
|
|
self.member_id,
|
|
self.claim_id,
|
|
title=self.variables["title"].get(),
|
|
due_date=self.variables["due_date"].get(),
|
|
base_amount=self.variables["base_amount"].get(),
|
|
description=self.variables["description"].get(),
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self)
|
|
return
|
|
self.destroy()
|
|
self.on_saved()
|
|
|
|
|
|
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 sorted(
|
|
data.payments,
|
|
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
|
reverse=True,
|
|
):
|
|
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)
|
|
if not self.payment_by_label:
|
|
ttk.Label(
|
|
self.frame,
|
|
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
|
|
style="Mono.TLabel",
|
|
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
|
|
amount_row = 2
|
|
else:
|
|
amount_row = 1
|
|
ttk.Label(self.frame, text="Betrag").grid(
|
|
row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12)
|
|
)
|
|
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
|
|
row=amount_row, column=1, sticky="ew", pady=5
|
|
)
|
|
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
|
self._buttons(amount_row + 1, 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):
|
|
"""Drafts the next reminder in the sequence for a claim. The level is computed
|
|
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
|
|
for name/deadline/fee items -- the matching dunning-policy level if one exists,
|
|
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
|
|
level slot as a regular reminder, since it's still the member's own failure to
|
|
provide cover/valid bank details), or a blank template for manual entry. The fee
|
|
items table itself is always free to edit no matter which preset was picked."""
|
|
|
|
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
|
|
data, _claim = repository.get_claim(member_id, claim_id)
|
|
existing_levels = {
|
|
int(item.get("level", 0))
|
|
for item in data.reminders
|
|
if str(item.get("claim_id", "")) == claim_id
|
|
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
|
}
|
|
self.level = max(existing_levels, default=0) + 1
|
|
self.policy = repository.get_reminder_policy()
|
|
self.items: list[dict[str, str]] = []
|
|
|
|
self.frame.columnconfigure(1, weight=1)
|
|
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
|
|
row=0, column=1, sticky="w", pady=5
|
|
)
|
|
|
|
self.preset_by_label = self._build_presets()
|
|
self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label)))
|
|
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
preset_combo = ttk.Combobox(
|
|
self.frame,
|
|
textvariable=self.preset_var,
|
|
values=list(self.preset_by_label),
|
|
state="readonly",
|
|
width=38,
|
|
)
|
|
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
|
|
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
|
|
|
|
self.name_var = tk.StringVar()
|
|
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Entry(self.frame, textvariable=self.name_var, width=45).grid(
|
|
row=2, column=1, sticky="ew", pady=5
|
|
)
|
|
|
|
self.detail_var = tk.StringVar()
|
|
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(
|
|
row=3, column=1, sticky="ew", pady=5
|
|
)
|
|
|
|
self.deadline_var = tk.StringVar()
|
|
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
|
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
|
|
)
|
|
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
|
|
|
|
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
|
ttk.Label(self.frame, text="Versandweg").grid(row=5, 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=5, column=1, sticky="ew", pady=5)
|
|
|
|
self._build_items_table(row=6)
|
|
self._apply_preset()
|
|
self._buttons(7, self._save)
|
|
|
|
def _build_presets(self) -> dict[str, dict]:
|
|
presets: dict[str, dict] = {}
|
|
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
|
|
if matching:
|
|
fee = decimal_value(matching["fee"])
|
|
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
|
"name": matching["name"],
|
|
"payment_deadline_days": matching["payment_deadline_days"],
|
|
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
|
}
|
|
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
|
presets["Rücklastschrift"] = {
|
|
"name": "Rücklastschrift",
|
|
"payment_deadline_days": 14,
|
|
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
|
}
|
|
presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []}
|
|
return presets
|
|
|
|
def _default_amount_for(self, needle: str) -> str:
|
|
for entry in self.policy["standard_fee_items"]:
|
|
if needle.casefold() in str(entry.get("description", "")).casefold():
|
|
return str(entry.get("default_amount", "0.00"))
|
|
return "0.00"
|
|
|
|
def _apply_preset(self) -> None:
|
|
preset = self.preset_by_label[self.preset_var.get()]
|
|
self.name_var.set(preset["name"])
|
|
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
|
self.items = [dict(item) for item in preset["items"]]
|
|
self._refresh_items()
|
|
|
|
def _build_items_table(self, row: int) -> None:
|
|
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
|
|
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
|
|
table_frame.columnconfigure(0, weight=1)
|
|
self.items_tree = ttk.Treeview(
|
|
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
|
|
)
|
|
self.items_tree.heading("description", text="Beschreibung")
|
|
self.items_tree.heading("amount", text="Betrag")
|
|
self.items_tree.column("description", width=220, anchor="w")
|
|
self.items_tree.column("amount", width=100, anchor="w", stretch=False)
|
|
self.items_tree.grid(row=0, column=0, sticky="ew")
|
|
|
|
controls = ttk.Frame(table_frame)
|
|
controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
|
self.item_description_var = tk.StringVar()
|
|
description_combo = ttk.Combobox(
|
|
controls,
|
|
textvariable=self.item_description_var,
|
|
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
|
width=22,
|
|
)
|
|
description_combo.pack(side="left", padx=(0, 8))
|
|
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
|
|
self.item_amount_var = tk.StringVar()
|
|
ttk.Entry(controls, textvariable=self.item_amount_var, width=10).pack(side="left", padx=(0, 8))
|
|
ttk.Button(controls, text="Position hinzufügen", command=self._add_item).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(controls, text="Position entfernen", command=self._remove_item).pack(side="left")
|
|
|
|
def _prefill_item_amount(self) -> None:
|
|
description = self.item_description_var.get()
|
|
for entry in self.policy["standard_fee_items"]:
|
|
if str(entry.get("description", "")) == description:
|
|
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
|
|
return
|
|
|
|
def _refresh_items(self) -> None:
|
|
self.items_tree.delete(*self.items_tree.get_children())
|
|
for index, item in enumerate(self.items):
|
|
self.items_tree.insert(
|
|
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
|
|
)
|
|
|
|
def _add_item(self) -> None:
|
|
description = self.item_description_var.get().strip()
|
|
if not description:
|
|
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
|
return
|
|
try:
|
|
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
|
except ValueError as exc:
|
|
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
|
return
|
|
self.items.append({"description": description, "amount": money_text(amount)})
|
|
self.item_description_var.set("")
|
|
self.item_amount_var.set("")
|
|
self._refresh_items()
|
|
|
|
def _remove_item(self) -> None:
|
|
selected = self.items_tree.selection()
|
|
if not selected:
|
|
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
|
return
|
|
del self.items[int(selected[0])]
|
|
self._refresh_items()
|
|
|
|
def _save(self):
|
|
try:
|
|
deadline_days = int(self.deadline_var.get())
|
|
except ValueError:
|
|
messagebox.showerror(
|
|
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
|
|
)
|
|
return
|
|
try:
|
|
self.repository.create_reminder_draft(
|
|
self.member_id,
|
|
self.claim_id,
|
|
level=self.level,
|
|
name=self.name_var.get(),
|
|
payment_deadline_days=deadline_days,
|
|
detail=self.detail_var.get(),
|
|
items=self.items,
|
|
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 ReminderMailDialog(tk.Toplevel):
|
|
def __init__(
|
|
self,
|
|
master,
|
|
repository,
|
|
member_id,
|
|
claim_id,
|
|
reminder_id,
|
|
reminder_name,
|
|
on_saved,
|
|
):
|
|
super().__init__(master)
|
|
self.repository = repository
|
|
self.member_id = member_id
|
|
self.claim_id = claim_id
|
|
self.reminder_id = reminder_id
|
|
self.reminder_name = reminder_name
|
|
self.on_saved = on_saved
|
|
organization = repository.get_configuration().get("organization") or {}
|
|
self.sender_name_var = tk.StringVar(
|
|
value=str(organization.get("name") or "Verwaltung C3MA")
|
|
)
|
|
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
|
self.title("Mahnung als E-Mail erzeugen")
|
|
self.transient(master)
|
|
self.resizable(True, False)
|
|
self.columnconfigure(1, weight=1)
|
|
ttk.Label(
|
|
self,
|
|
text=(
|
|
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
|
|
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
|
|
),
|
|
wraplength=620,
|
|
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
|
|
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
|
|
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
|
|
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
|
|
)
|
|
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
|
|
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
|
|
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
|
|
)
|
|
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
|
|
self.signature = tk.Text(self, width=55, height=5, wrap="word")
|
|
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
|
|
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
|
buttons = ttk.Frame(self, padding=14)
|
|
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
|
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
|
ttk.Button(
|
|
buttons,
|
|
text="EML speichern und als versandt buchen",
|
|
style="Accent.TButton",
|
|
command=self._generate,
|
|
).pack(side="left", padx=(8, 0))
|
|
self.grab_set()
|
|
|
|
def _generate(self) -> None:
|
|
selected = filedialog.asksaveasfilename(
|
|
parent=self,
|
|
title="Mahnung als Thunderbird-Entwurf speichern",
|
|
defaultextension=".eml",
|
|
initialfile=f"{self.reminder_name}.eml",
|
|
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
|
)
|
|
if not selected:
|
|
return
|
|
try:
|
|
result = generate_and_send_reminder_mail(
|
|
self.repository,
|
|
self.member_id,
|
|
self.claim_id,
|
|
self.reminder_id,
|
|
output_path=selected,
|
|
sender_name=self.sender_name_var.get(),
|
|
sender_email=self.sender_email_var.get(),
|
|
signature=self.signature.get("1.0", "end-1c"),
|
|
)
|
|
except (OSError, RepositoryError) as exc:
|
|
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
|
return
|
|
messagebox.showinfo(
|
|
"Mahnung erzeugt",
|
|
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
|
|
parent=self,
|
|
)
|
|
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()
|