mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
feat: add staged reminder workflow
This commit is contained in:
+177
-20
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user