mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Mahnungs- and SEPA-info-mails could previously only be saved as a local .eml file that still had to be manually imported into Thunderbird. Add a per-store "E-Mail-Versand" configuration (Optionen -> E-Mail-Versand, stored in repository.json alongside the rest of the club's settings, since different stores may use different mailboxes) with four delivery modes: - "Lokal speichern": today's behaviour, unchanged default for existing stores. - "Direkt versenden": sends via SMTP. - "Als Entwurf ablegen": IMAP APPENDs into a configurable drafts folder, so it shows up live in whatever mail client is already watching that account. - "Jedes Mal fragen": prompts once per generation action (not per e-mail -- a SEPA batch can cover dozens of members) with Senden/Entwürfe/Abbrechen. New ccma.services.mail_delivery module (smtplib/imaplib, no new dependency) opens one authenticated connection per batch and reuses it across all messages instead of reconnecting per recipient. Both "Verbindung testen" buttons in Options exercise the same connection path used for real delivery. The archived per-member copy of every generated e-mail is unaffected and still always written regardless of delivery mode. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1334 lines
58 KiB
Python
1334 lines
58 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.email_delivery_dialog import resolve_delivery_mode
|
|
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))
|
|
self.edit_reminder_button = ttk.Button(
|
|
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
|
|
)
|
|
self.edit_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)
|
|
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
|
|
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
|
|
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
|
|
|
|
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 _edit_reminder(self) -> None:
|
|
reminder = self._selected_reminder()
|
|
if not reminder:
|
|
return
|
|
if str(reminder.get("status", "")) == "sent":
|
|
if not messagebox.askyesno(
|
|
"Versand zurücksetzen",
|
|
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
|
|
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
|
|
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
|
|
"erneut versandt werden. Fortfahren?",
|
|
parent=self,
|
|
):
|
|
return
|
|
try:
|
|
reminder = self.repository.revert_reminder_sent(
|
|
self.member_id, self.claim_id, str(reminder["reminder_id"])
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
|
|
return
|
|
self.refresh()
|
|
ReminderDialog(
|
|
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
|
|
)
|
|
|
|
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, *, reminder: dict | None = None):
|
|
super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
|
|
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
|
|
# of freely-editable fee items whose content varies a lot -- make it resizable
|
|
# so a long/multi-item Mahnung doesn't get cramped.
|
|
self.resizable(True, True)
|
|
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
|
self.editing_reminder = reminder
|
|
if reminder:
|
|
self.level = int(reminder.get("level", 1))
|
|
else:
|
|
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)
|
|
self.frame.rowconfigure(6, 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()
|
|
default_preset = (
|
|
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
|
|
)
|
|
self.preset_var = tk.StringVar(value=default_preset)
|
|
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=55,
|
|
)
|
|
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=64).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=64).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)
|
|
if reminder:
|
|
self.name_var.set(str(reminder.get("name", "")))
|
|
self.detail_var.set(str(reminder.get("detail", "")))
|
|
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
|
|
self.channel_var.set(
|
|
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
|
|
)
|
|
self.items = [dict(item) for item in reminder.get("items") or []]
|
|
self._refresh_items()
|
|
else:
|
|
self._apply_preset()
|
|
self._buttons(7, self._save)
|
|
# Size to fit the fully built content (incl. the preset-populated items table)
|
|
# instead of whatever size Tk guessed before everything was in place -- the
|
|
# base dialog sets resizable(False, False) before this subclass's content
|
|
# exists, so an explicit geometry() is needed or the window stays clipped.
|
|
# The floor of 700 keeps the dialog comfortably wide even for a short name/
|
|
# detail; the user can still grow it further since it's resizable now.
|
|
self.update_idletasks()
|
|
width = max(700, self.winfo_reqwidth())
|
|
height = self.winfo_reqheight()
|
|
self.geometry(f"{width}x{height}")
|
|
self.minsize(width, height)
|
|
|
|
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"],
|
|
"detail": "",
|
|
"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[f"Stufe {self.level}: Rücklastschrift"] = {
|
|
"name": "Rücklastschrift",
|
|
# Prefilled so the member sees why they're being charged; the board can
|
|
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
|
|
# right here since "Details" goes straight into the reminder email as-is.
|
|
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
|
|
"payment_deadline_days": 14,
|
|
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
|
}
|
|
presets["Frei (eigene Eingabe)"] = {
|
|
"name": "", "detail": "", "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.detail_var.set(preset["detail"])
|
|
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)
|
|
table_frame.rowconfigure(0, weight=1)
|
|
amount_column_width = 100
|
|
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=320, anchor="w")
|
|
self.items_tree.column("amount", width=amount_column_width, anchor="w", stretch=False)
|
|
self.items_tree.grid(row=0, column=0, sticky="nsew")
|
|
self.items_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_item())
|
|
|
|
# Edit row lines up under the table's own columns: "Beschreibung" stretches to
|
|
# fill remaining space just like that Treeview column does, while "Betrag" sits
|
|
# in a frame pinned to the exact same pixel width as the fixed "amount" column
|
|
# above it (Entry's width= is in characters, not pixels, so a fixed-size frame
|
|
# is what gets a genuinely exact match instead of an approximate ratio).
|
|
edit_row = ttk.Frame(table_frame)
|
|
edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
|
edit_row.columnconfigure(0, weight=1)
|
|
edit_row.columnconfigure(1, weight=0)
|
|
self.item_description_var = tk.StringVar()
|
|
description_combo = ttk.Combobox(
|
|
edit_row,
|
|
textvariable=self.item_description_var,
|
|
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
|
)
|
|
description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8))
|
|
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
|
|
amount_frame = ttk.Frame(edit_row, width=amount_column_width)
|
|
amount_frame.grid(row=0, column=1, sticky="ns")
|
|
amount_frame.pack_propagate(False)
|
|
self.item_amount_var = tk.StringVar()
|
|
ttk.Entry(amount_frame, textvariable=self.item_amount_var).pack(fill="both", expand=True)
|
|
|
|
buttons_row = ttk.Frame(table_frame)
|
|
buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0))
|
|
ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(buttons_row, text="Aktualisieren", command=self._update_item).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(buttons_row, 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 _load_selected_item(self) -> None:
|
|
selected = self.items_tree.selection()
|
|
if not selected:
|
|
return
|
|
item = self.items[int(selected[0])]
|
|
self.item_description_var.set(item["description"])
|
|
self.item_amount_var.set(item["amount"])
|
|
|
|
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 _update_item(self) -> None:
|
|
selected = self.items_tree.selection()
|
|
if not selected:
|
|
messagebox.showerror(
|
|
"Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self
|
|
)
|
|
return
|
|
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[int(selected[0])] = {"description": description, "amount": money_text(amount)}
|
|
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.item_description_var.set("")
|
|
self.item_amount_var.set("")
|
|
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:
|
|
if self.editing_reminder:
|
|
self.repository.edit_reminder_draft(
|
|
self.member_id,
|
|
self.claim_id,
|
|
str(self.editing_reminder["reminder_id"]),
|
|
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()),
|
|
)
|
|
else:
|
|
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:
|
|
delivery_mode = resolve_delivery_mode(self, self.repository)
|
|
if delivery_mode is None:
|
|
return
|
|
output_path = None
|
|
if delivery_mode == "local":
|
|
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
|
|
output_path = selected
|
|
try:
|
|
result = generate_and_send_reminder_mail(
|
|
self.repository,
|
|
self.member_id,
|
|
self.claim_id,
|
|
self.reminder_id,
|
|
delivery_mode=delivery_mode,
|
|
output_path=output_path,
|
|
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
|
|
outcome = {
|
|
"local": f"gespeichert:\n{result.export_path}",
|
|
"send": "direkt versandt.",
|
|
"drafts": "als Entwurf im Postfach abgelegt.",
|
|
}[delivery_mode]
|
|
messagebox.showinfo(
|
|
"Mahnung erzeugt",
|
|
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
|
|
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()
|