feat: add staged reminder workflow

This commit is contained in:
Marcel Peterkau
2026-06-21 18:40:54 +02:00
parent 288b5f6247
commit e6d2f77d1e
9 changed files with 630 additions and 43 deletions
+177 -20
View File
@@ -19,7 +19,13 @@ from ccma.domain.contributions import (
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.labels import CLAIM_ITEM_TYPE_LABELS, display_label, storage_key
from ccma.ui.labels import (
CLAIM_ITEM_TYPE_LABELS,
REMINDER_CHANNEL_LABELS,
REMINDER_STATUS_LABELS,
display_label,
storage_key,
)
class ClaimTab(ttk.Frame):
@@ -86,6 +92,8 @@ class ClaimTab(ttk.Frame):
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")
@@ -135,19 +143,34 @@ class ClaimTab(ttk.Frame):
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")
self.reminders = ttk.Treeview(
parent,
columns=("created", "level", "name", "status", "deadline", "fee"),
show="headings",
)
for key, title, width in (
("created", "Erstellt", 150),
("level", "Stufe", 80),
("detail", "Details", 430),
("fee", "Gebühr", 100),
("level", "Stufe", 60),
("name", "Mahnung", 230),
("status", "Status", 130),
("deadline", "Zahlungsfrist", 120),
("fee", "Gebühr", 90),
):
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)
self.reminders.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
buttons = ttk.Frame(parent)
buttons.grid(row=1, column=0, sticky="e", pady=(10, 0))
self.discard_reminder_button = ttk.Button(
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
)
self.discard_reminder_button.pack(side="left", padx=(0, 8))
self.send_reminder_button = ttk.Button(
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
)
self.send_reminder_button.pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="left")
def refresh(self) -> None:
try:
@@ -165,11 +188,17 @@ class ClaimTab(ttk.Frame):
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_positions()
self._render_payments()
self._render_reminders()
@@ -217,6 +246,7 @@ class ClaimTab(ttk.Frame):
if str(reminder.get("claim_id", "")) != self.claim_id:
continue
fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
status = str(reminder.get("status", "draft"))
self.reminders.insert(
"",
"end",
@@ -224,10 +254,13 @@ class ClaimTab(ttk.Frame):
values=(
str(reminder.get("created_at", ""))[:16],
reminder.get("level", ""),
reminder.get("detail", ""),
fee_item.get("amount", "0.00"),
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
display_label(REMINDER_STATUS_LABELS, status),
format_date_for_display(str(reminder.get("payment_deadline") or "")),
fee_item.get("amount", reminder.get("fee", "0.00")),
),
)
self._update_reminder_buttons()
def _add_item(self) -> None:
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
@@ -255,6 +288,64 @@ class ClaimTab(ttk.Frame):
def _add_reminder(self) -> None:
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
def _selected_reminder(self) -> dict | None:
selected = self.reminders.selection()
if not selected:
return None
return next(
(item for item in self.data.reminders if str(item.get("reminder_id", "")) == selected[0]),
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
@@ -278,11 +369,11 @@ class _Dialog(tk.Toplevel):
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())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
@@ -432,32 +523,98 @@ class AllocatePaymentDialog(_Dialog):
class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung erfassen", on_saved)
super().__init__(master, "Mahnung vorbereiten", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.level_var = tk.IntVar(value=1)
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(value="0.00")
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))
ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid(
row=0, column=1, sticky="w", pady=5
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)
self._buttons(3, self._save)
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.add_reminder(
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=self.level_var.get(),
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 (tk.TclError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self)
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()