mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
_execute_housekeeper_run() showed the success dialog but never called refresh()/on_changed(), so retroactively created claims/payments stayed invisible in the currently open Mitgliedsakte until it was closed and reopened, and the overview list wasn't notified either. Match the pattern used by every other mutating action in this file (save, payment, credit, reminder, ...): refresh the tab's own contribution lists and notify the overview callback right after the run succeeds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1213 lines
51 KiB
Python
1213 lines
51 KiB
Python
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from tkinter import messagebox, ttk
|
|
|
|
from ccma.config import AppConfig
|
|
from ccma.domain.contributions import (
|
|
CLAIM_STATUS_LABELS,
|
|
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
|
DONATION_STATUS_LABELS,
|
|
claim_status,
|
|
claim_total,
|
|
donation_allocated_total,
|
|
donation_balance,
|
|
donation_status,
|
|
money_text,
|
|
payment_allocated_total,
|
|
)
|
|
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
|
|
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
|
|
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
|
from ccma.ui.dialogs import IntegrityWarningDialog
|
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
|
from ccma.ui.donation_dialog import (
|
|
AllocateDonationPaymentDialog,
|
|
DonationEditDialog,
|
|
DonationPaymentDialog,
|
|
)
|
|
from ccma.ui.file_open import open_path
|
|
from ccma.ui.gnucash_import_dialog import GnuCashImportDialog
|
|
from ccma.ui.labels import display_label, storage_key
|
|
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
|
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
|
from ccma.ui.scrolling import ScrollableFrame
|
|
|
|
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
|
# and amount-like columns get the same fixed width, status columns get a third more
|
|
# (they tend to hold longer label text), and only the trailing description/reference
|
|
# column stretches to take up whatever space remains.
|
|
FIXED_COLUMN_WIDTH = 90
|
|
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)
|
|
|
|
CLAIM_TABLE_COLUMNS = (
|
|
("due", "Fällig", FIXED_COLUMN_WIDTH),
|
|
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
|
("status", "Status", STATUS_COLUMN_WIDTH),
|
|
("title", "Forderung", 220),
|
|
)
|
|
|
|
|
|
def _claim_sort_value(data, claim: dict, column: str) -> str:
|
|
if column == "title":
|
|
return str(claim.get("title", ""))
|
|
if column == "due":
|
|
return str(claim.get("due_date", ""))
|
|
if column == "amount":
|
|
return f"{claim_total(claim):012.2f}"
|
|
if column == "status":
|
|
status = claim_status(data, claim)
|
|
return CLAIM_STATUS_LABELS.get(status, status.upper())
|
|
return ""
|
|
|
|
|
|
class _RunHousekeeperDialog(tk.Toplevel):
|
|
"""Confirms a housekeeper run scoped to a single member, with the option to
|
|
force retroactive claim creation for just this run without touching the
|
|
application-wide default setting."""
|
|
|
|
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
|
|
super().__init__(master)
|
|
self.on_confirm = on_confirm
|
|
self.title("Hausmeister für dieses Mitglied ausführen")
|
|
self.transient(master.winfo_toplevel())
|
|
self.resizable(False, False)
|
|
frame = ttk.Frame(self, padding=18)
|
|
frame.pack(fill="both", expand=True)
|
|
ttk.Label(
|
|
frame,
|
|
text=(
|
|
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
|
|
"Andere Mitglieder bleiben unberührt."
|
|
),
|
|
wraplength=380,
|
|
justify="left",
|
|
).pack(anchor="w", pady=(0, 12))
|
|
self.retroactive_var = tk.BooleanVar(value=False)
|
|
ttk.Checkbutton(
|
|
frame,
|
|
text="Rückwirkende Beiträge/Forderungen erstellen",
|
|
variable=self.retroactive_var,
|
|
).pack(anchor="w", pady=(0, 14))
|
|
buttons = ttk.Frame(frame)
|
|
buttons.pack(anchor="e")
|
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
|
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
|
|
side="left"
|
|
)
|
|
self.bind("<Escape>", lambda _event: self.destroy())
|
|
self.after_idle(self.grab_set)
|
|
|
|
def _confirm(self) -> None:
|
|
retroactive = self.retroactive_var.get()
|
|
self.destroy()
|
|
self.on_confirm(retroactive)
|
|
|
|
|
|
class MemberTab(ttk.Frame):
|
|
def __init__(
|
|
self,
|
|
master: tk.Misc,
|
|
repository: MemberRepository,
|
|
member_id: str,
|
|
config: AppConfig,
|
|
on_close: Callable[[], None],
|
|
on_changed: Callable[[], None],
|
|
on_open_claim: Callable[[str, str], None],
|
|
on_open_assets: Callable[[], None],
|
|
on_open_asset: Callable[[str], None],
|
|
on_return_asset: Callable[[str], None],
|
|
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
|
|
):
|
|
super().__init__(master, padding=12)
|
|
self.repository = repository
|
|
self.member_id = member_id
|
|
self.config = config
|
|
self.on_close = on_close
|
|
self.on_changed = on_changed
|
|
self.on_open_claim = on_open_claim
|
|
self.on_open_assets = on_open_assets
|
|
self.on_open_asset = on_open_asset
|
|
self.on_return_asset = on_return_asset
|
|
self.on_run_housekeeper = on_run_housekeeper
|
|
self.member = repository.get_member(member_id)
|
|
self.variables: dict[str, tk.Variable] = {}
|
|
self._field_sections: dict[str, str] = {}
|
|
self._dirty_sections: set[str] = set()
|
|
self._form_tabs: dict[str, str] = {}
|
|
self._form_tab_titles: dict[str, str] = {}
|
|
self._loading = False
|
|
self.notes_text: tk.Text | None = None
|
|
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, 10))
|
|
header.columnconfigure(0, weight=1)
|
|
header.columnconfigure(1, weight=0)
|
|
self.title_var = tk.StringVar()
|
|
self.status_var = tk.StringVar()
|
|
self.id_var = tk.StringVar()
|
|
title_column = ttk.Frame(header)
|
|
title_column.grid(row=0, column=0, sticky="ew")
|
|
title_column.columnconfigure(0, weight=1)
|
|
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
|
|
row=0, column=0, sticky="w"
|
|
)
|
|
ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid(
|
|
row=1, column=0, sticky="w", pady=(3, 0)
|
|
)
|
|
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
|
|
row=2, column=0, sticky="w", pady=(3, 0)
|
|
)
|
|
header_actions = ttk.Frame(header)
|
|
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
|
|
ttk.Button(
|
|
header_actions,
|
|
text="Hausmeister für dieses Mitglied",
|
|
command=self._run_housekeeper_for_member,
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
|
|
self.messages = MessageBannerList(self)
|
|
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
|
self.messages.grid_remove()
|
|
|
|
self.pane = ttk.Panedwindow(self, orient="horizontal")
|
|
self.pane.grid(row=2, column=0, sticky="nsew")
|
|
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
|
|
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
|
|
self.pane.add(self.details_pane, weight=2)
|
|
self.pane.add(self.timeline_pane, weight=1)
|
|
self._build_details(self.details_pane)
|
|
self._build_timeline(self.timeline_pane)
|
|
self._pane_position_initialized = False
|
|
self.pane.bind("<Configure>", self._set_initial_pane_position, add="+")
|
|
|
|
def _set_initial_pane_position(self, event: tk.Event | None = None) -> None:
|
|
if self._pane_position_initialized:
|
|
return
|
|
try:
|
|
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
|
|
if width > 1:
|
|
# Chronik (timeline_pane) defaults to a third of the available width.
|
|
self.pane.sashpos(0, max(360, round(width * 2 / 3)))
|
|
self._pane_position_initialized = True
|
|
except tk.TclError:
|
|
return
|
|
|
|
def _build_details(self, parent: ttk.Frame) -> None:
|
|
parent.columnconfigure(0, weight=1)
|
|
parent.rowconfigure(1, weight=1)
|
|
ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 8)
|
|
)
|
|
notebook = ttk.Notebook(parent)
|
|
self.details_notebook = notebook
|
|
notebook.grid(row=1, column=0, sticky="nsew")
|
|
data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
|
|
address_tab = self._create_form_tab(notebook, "address", "Anschrift")
|
|
banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
|
|
actions = ttk.Frame(parent)
|
|
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
|
|
actions.columnconfigure(0, weight=1)
|
|
ttk.Button(
|
|
actions,
|
|
text="Mitgliedsdaten speichern",
|
|
style="Accent.TButton",
|
|
command=self._save,
|
|
).grid(row=0, column=0, sticky="e")
|
|
contribution_tab = ttk.Frame(notebook, padding=16)
|
|
contribution_override_tab = ttk.Frame(notebook, padding=16)
|
|
payments_tab = ttk.Frame(notebook, padding=16)
|
|
donations_tab = ttk.Frame(notebook, padding=16)
|
|
assets_tab = ttk.Frame(notebook, padding=16)
|
|
documents_tab = ttk.Frame(notebook, padding=16)
|
|
notebook.add(contribution_tab, text="Forderungen")
|
|
notebook.add(contribution_override_tab, text="Beitrag")
|
|
notebook.add(payments_tab, text="Zahlungen")
|
|
notebook.add(donations_tab, text="Spenden")
|
|
notebook.add(assets_tab, text="Assets")
|
|
notebook.add(documents_tab, text="Dokumente")
|
|
|
|
fields = [
|
|
("UUID / Ordner-ID", "member_id"),
|
|
("Mitgliedsnummer", "member_number"),
|
|
("Vorname", "first_name"),
|
|
("Nachname", "last_name"),
|
|
("Nickname", "nickname"),
|
|
("E-Mail-Adresse", "email"),
|
|
("Telefonnummer", "phone"),
|
|
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
|
(f"Antragsdatum ({date_input_hint()})", "application_date"),
|
|
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
|
|
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
|
|
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
|
|
]
|
|
for row, (label, key) in enumerate(fields):
|
|
variable = tk.StringVar()
|
|
self._add_variable(key, variable, "data")
|
|
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
|
|
if key == "birth_date":
|
|
birth_row = ttk.Frame(data_tab)
|
|
birth_row.grid(row=row, column=1, sticky="ew", pady=5)
|
|
birth_row.columnconfigure(0, weight=1)
|
|
ttk.Entry(birth_row, textvariable=variable, width=24).grid(row=0, column=0, sticky="ew")
|
|
self.age_var = tk.StringVar(value="Alter: —")
|
|
ttk.Label(birth_row, textvariable=self.age_var, style="Mono.TLabel").grid(
|
|
row=0, column=1, sticky="w", padx=(10, 0)
|
|
)
|
|
variable.trace_add(
|
|
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
|
|
)
|
|
else:
|
|
entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
|
|
ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
|
|
row=row, column=1, sticky="ew", pady=5
|
|
)
|
|
self._add_variable("status", tk.StringVar(), "data")
|
|
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
|
|
ttk.Combobox(
|
|
data_tab,
|
|
textvariable=self.variables["status"],
|
|
values=list(STATUS_LABELS.values()),
|
|
state="readonly",
|
|
width=39,
|
|
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
|
ttk.Label(data_tab, text="Interne Notiz").grid(
|
|
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
|
)
|
|
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
|
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
|
|
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
|
|
data_tab.columnconfigure(1, weight=1)
|
|
|
|
address_fields = (
|
|
("Straße und Hausnummer", "street"),
|
|
("Adresszusatz", "address_addition"),
|
|
("Postleitzahl", "postal_code"),
|
|
("Ort", "city"),
|
|
("Land", "country"),
|
|
)
|
|
address_tab.columnconfigure(1, weight=1)
|
|
for row, (label, key) in enumerate(address_fields):
|
|
self._add_variable(key, tk.StringVar(), "address")
|
|
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
|
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
|
|
row=row, column=1, sticky="ew", pady=5
|
|
)
|
|
|
|
banking_fields = (
|
|
("Kontoinhaber", "account_holder"),
|
|
("IBAN", "iban"),
|
|
("BIC", "bic"),
|
|
("Mandatsreferenz", "mandate_reference"),
|
|
(f"Mandat erteilt am ({date_input_hint()})", "mandate_signed_at"),
|
|
(f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
|
|
)
|
|
banking_tab.columnconfigure(1, weight=1)
|
|
self._add_variable("payment_frequency", tk.StringVar(), "banking")
|
|
ttk.Label(banking_tab, text="Zahlweise").grid(
|
|
row=0, column=0, sticky="w", padx=(0, 12), pady=5
|
|
)
|
|
ttk.Combobox(
|
|
banking_tab,
|
|
textvariable=self.variables["payment_frequency"],
|
|
values=list(PAYMENT_FREQUENCY_LABELS.values()),
|
|
state="readonly",
|
|
width=39,
|
|
).grid(row=0, column=1, sticky="ew", pady=5)
|
|
for row, (label, key) in enumerate(banking_fields, start=1):
|
|
self._add_variable(key, tk.StringVar(), "banking")
|
|
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
|
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
|
|
row=row, column=1, sticky="ew", pady=5
|
|
)
|
|
self._add_variable("mandate_active", tk.BooleanVar(), "banking")
|
|
ttk.Checkbutton(
|
|
banking_tab,
|
|
text="SEPA-Lastschriftmandat ist aktiv",
|
|
variable=self.variables["mandate_active"],
|
|
style="Switch",
|
|
).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
|
|
contribution_tab.columnconfigure(0, weight=1)
|
|
contribution_tab.rowconfigure(1, weight=1)
|
|
self.contribution_summary = tk.StringVar()
|
|
ttk.Label(contribution_tab, textvariable=self.contribution_summary, style="Mono.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 10)
|
|
)
|
|
self.claims = ttk.Treeview(
|
|
contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
|
|
)
|
|
self.claim_sort_column = "due"
|
|
self.claim_sort_descending = False
|
|
for key, title, width in CLAIM_TABLE_COLUMNS:
|
|
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
|
|
self.claims.column(key, width=width, anchor="w", stretch=key == "title")
|
|
self.claims.grid(row=1, column=0, sticky="nsew")
|
|
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
|
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
|
|
|
contribution_override_tab.columnconfigure(0, weight=1)
|
|
contribution_override_tab.rowconfigure(1, weight=1)
|
|
ttk.Label(
|
|
contribution_override_tab,
|
|
text=(
|
|
"Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
|
|
"den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
|
|
"Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
|
|
),
|
|
style="Mono.TLabel",
|
|
wraplength=700,
|
|
).grid(row=0, column=0, sticky="w", pady=(0, 10))
|
|
self.contribution_overrides = ttk.Treeview(
|
|
contribution_override_tab,
|
|
columns=("valid_from", "valid_until", "kind", "value", "reason"),
|
|
show="headings",
|
|
selectmode="browse",
|
|
)
|
|
for key, title, width in (
|
|
("valid_from", "Ab", FIXED_COLUMN_WIDTH),
|
|
("valid_until", "Bis", FIXED_COLUMN_WIDTH),
|
|
("kind", "Art", FIXED_COLUMN_WIDTH),
|
|
("value", "Wert", FIXED_COLUMN_WIDTH),
|
|
("reason", "Begründung", 320),
|
|
):
|
|
self.contribution_overrides.heading(key, text=title)
|
|
self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
|
|
self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
|
|
self.contribution_overrides.bind(
|
|
"<Double-1>", lambda _event: self._edit_selected_contribution_override()
|
|
)
|
|
self.contribution_overrides.bind(
|
|
"<Return>", lambda _event: self._edit_selected_contribution_override()
|
|
)
|
|
contribution_override_actions = ttk.Frame(contribution_override_tab)
|
|
contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
|
ttk.Button(
|
|
contribution_override_actions,
|
|
text="Abweichenden Beitrag anlegen",
|
|
command=self._create_contribution_override,
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(
|
|
contribution_override_actions,
|
|
text="Bearbeiten",
|
|
command=self._edit_selected_contribution_override,
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(
|
|
contribution_override_actions,
|
|
text="Löschen",
|
|
command=self._delete_selected_contribution_override,
|
|
).pack(side="left")
|
|
|
|
payments_tab.columnconfigure(0, weight=1)
|
|
payments_tab.rowconfigure(1, weight=1)
|
|
self.payment_summary = tk.StringVar()
|
|
ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 10)
|
|
)
|
|
self.payments = ttk.Treeview(
|
|
payments_tab,
|
|
columns=("date", "amount", "allocated", "available", "reference"),
|
|
show="headings",
|
|
selectmode="browse",
|
|
)
|
|
for key, title, width in (
|
|
("date", "Datum", FIXED_COLUMN_WIDTH),
|
|
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
|
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
|
|
("available", "Frei", FIXED_COLUMN_WIDTH),
|
|
("reference", "Referenz", 320),
|
|
):
|
|
self.payments.heading(key, text=title)
|
|
self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
|
|
self.payments.grid(row=1, column=0, sticky="nsew")
|
|
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
|
|
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
|
|
payment_actions = ttk.Frame(payments_tab)
|
|
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
|
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
|
|
side="left"
|
|
)
|
|
ttk.Separator(payment_actions, orient="vertical").pack(side="left", fill="y", padx=10)
|
|
ttk.Button(
|
|
payment_actions, text="Zahlungen importieren", command=self._import_payments
|
|
).pack(side="left")
|
|
|
|
donations_tab.columnconfigure(0, weight=1)
|
|
donations_tab.rowconfigure(1, weight=1)
|
|
self.donation_summary = tk.StringVar()
|
|
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 10)
|
|
)
|
|
self.donations = ttk.Treeview(
|
|
donations_tab,
|
|
columns=("date", "amount", "allocated", "balance", "status", "reference"),
|
|
show="headings",
|
|
selectmode="browse",
|
|
)
|
|
for key, title, width in (
|
|
("date", "Datum", FIXED_COLUMN_WIDTH),
|
|
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
|
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
|
|
("balance", "Offen", FIXED_COLUMN_WIDTH),
|
|
("status", "Status", STATUS_COLUMN_WIDTH),
|
|
("reference", "Referenz / Zweck", 260),
|
|
):
|
|
self.donations.heading(key, text=title)
|
|
self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
|
|
self.donations.grid(row=1, column=0, sticky="nsew")
|
|
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
|
|
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
|
|
donation_actions = ttk.Frame(donations_tab)
|
|
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
|
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
|
|
ttk.Button(
|
|
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
|
|
side="left"
|
|
)
|
|
|
|
assets_tab.columnconfigure(0, weight=1)
|
|
assets_tab.rowconfigure(1, weight=1)
|
|
self.assets_summary = tk.StringVar()
|
|
ttk.Label(assets_tab, textvariable=self.assets_summary, style="Mono.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 10)
|
|
)
|
|
self.assets_tree = ttk.Treeview(
|
|
assets_tab,
|
|
columns=("label", "category", "inventory_number", "status"),
|
|
show="headings",
|
|
)
|
|
for key, title, width in (
|
|
("label", "Bezeichnung", 240),
|
|
("category", "Kategorie", 140),
|
|
("inventory_number", "Inventarnummer", 140),
|
|
("status", "Status", 140),
|
|
):
|
|
self.assets_tree.heading(key, text=title)
|
|
self.assets_tree.column(key, width=width, anchor="w")
|
|
self.assets_tree.grid(row=1, column=0, sticky="nsew")
|
|
self.assets_tree.bind("<Double-1>", lambda _event: self._open_selected_asset())
|
|
self.assets_tree.bind("<Return>", lambda _event: self._open_selected_asset())
|
|
asset_actions = ttk.Frame(assets_tab)
|
|
asset_actions.grid(row=2, column=0, sticky="e", pady=(10, 0))
|
|
ttk.Button(asset_actions, text="Inventar öffnen", command=self.on_open_assets).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
ttk.Button(
|
|
asset_actions,
|
|
text="Ausgewähltes Asset zurücknehmen",
|
|
command=self._return_selected_asset,
|
|
).pack(
|
|
side="left",
|
|
)
|
|
|
|
documents_tab.columnconfigure(0, weight=1)
|
|
documents_tab.rowconfigure(1, weight=1)
|
|
document_buttons = ttk.Frame(documents_tab)
|
|
document_buttons.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
|
ttk.Button(
|
|
document_buttons,
|
|
text="Dokument aus Template",
|
|
style="Accent.TButton",
|
|
command=self._create_document,
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
|
|
self.documents = ttk.Treeview(
|
|
documents_tab,
|
|
columns=("name", "type", "modified", "size"),
|
|
show="headings",
|
|
)
|
|
for key, title, width in (
|
|
("name", "Dokument", 280),
|
|
("type", "Typ", 65),
|
|
("modified", "Geändert", 135),
|
|
("size", "Größe", 80),
|
|
):
|
|
self.documents.heading(key, text=title)
|
|
self.documents.column(key, width=width, anchor="w")
|
|
self.documents.grid(row=1, column=0, sticky="nsew")
|
|
document_scroll = ttk.Scrollbar(
|
|
documents_tab,
|
|
orient="vertical",
|
|
command=self.documents.yview,
|
|
)
|
|
document_scroll.grid(row=1, column=1, sticky="ns")
|
|
self.documents.configure(yscrollcommand=document_scroll.set)
|
|
self.documents.bind("<Double-1>", lambda _event: self._open_selected_document())
|
|
self.documents.bind("<Return>", lambda _event: self._open_selected_document())
|
|
self.document_paths: dict[str, Path] = {}
|
|
|
|
def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
|
|
tab = ttk.Frame(notebook)
|
|
tab.columnconfigure(0, weight=1)
|
|
tab.rowconfigure(0, weight=1)
|
|
scroller = ScrollableFrame(tab, padding=16)
|
|
scroller.grid(row=0, column=0, sticky="nsew")
|
|
notebook.add(tab, text=title)
|
|
self._form_tabs[section] = str(tab)
|
|
self._form_tab_titles[section] = title
|
|
return scroller.content
|
|
|
|
def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
|
|
self.variables[key] = variable
|
|
self._field_sections[key] = section
|
|
variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))
|
|
|
|
def _mark_dirty(self, section: str) -> None:
|
|
if self._loading:
|
|
return
|
|
self._dirty_sections.add(section)
|
|
self._refresh_dirty_tabs()
|
|
|
|
def _mark_dirty_from_text(self, section: str) -> None:
|
|
if self.notes_text is None or not self.notes_text.edit_modified():
|
|
return
|
|
self.notes_text.edit_modified(False)
|
|
self._mark_dirty(section)
|
|
|
|
def _refresh_dirty_tabs(self) -> None:
|
|
for section, tab_id in self._form_tabs.items():
|
|
title = self._form_tab_titles[section]
|
|
suffix = " *" if section in self._dirty_sections else ""
|
|
self.details_notebook.tab(tab_id, text=f"{title}{suffix}")
|
|
|
|
def _clear_dirty(self) -> None:
|
|
self._dirty_sections.clear()
|
|
self._refresh_dirty_tabs()
|
|
|
|
def _close(self) -> None:
|
|
if self._dirty_sections and not messagebox.askokcancel(
|
|
"Ungespeicherte Änderungen",
|
|
"Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
|
|
parent=self,
|
|
):
|
|
return
|
|
self.on_close()
|
|
|
|
def _run_housekeeper_for_member(self) -> None:
|
|
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
|
|
|
|
def _execute_housekeeper_run(self, retroactive: bool) -> None:
|
|
try:
|
|
findings = self.on_run_housekeeper(self.member_id, retroactive)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
member_findings = [item for item in findings if item.member_id == self.member_id]
|
|
messagebox.showinfo(
|
|
"Hausmeister ausgeführt",
|
|
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
|
|
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
|
|
parent=self,
|
|
)
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _build_timeline(self, parent: ttk.Frame) -> None:
|
|
parent.columnconfigure(0, weight=1)
|
|
parent.rowconfigure(1, weight=1)
|
|
ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid(
|
|
row=0, column=0, sticky="w", pady=(0, 8)
|
|
)
|
|
self.timeline = ttk.Treeview(
|
|
parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview"
|
|
)
|
|
self.timeline.heading("time", text="Zeit")
|
|
self.timeline.heading("summary", text="Ereignis")
|
|
self.timeline.column("time", width=135, stretch=False)
|
|
self.timeline.column("summary", width=320, stretch=True)
|
|
self.timeline.grid(row=1, column=0, sticky="nsew")
|
|
compose = ttk.Frame(parent)
|
|
compose.grid(row=2, column=0, sticky="ew", pady=(10, 0))
|
|
compose.columnconfigure(0, weight=1)
|
|
self.comment_var = tk.StringVar()
|
|
comment = ttk.Entry(compose, textvariable=self.comment_var)
|
|
comment.grid(row=0, column=0, sticky="ew", padx=(0, 6))
|
|
comment.bind("<Return>", lambda _event: self._add_comment())
|
|
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
|
|
|
|
def refresh(self) -> None:
|
|
self._loading = True
|
|
self.member = self.repository.get_member(self.member_id)
|
|
self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}")
|
|
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
|
|
self.id_var.set(f"UUID: {self.member.member_id}")
|
|
warnings = self.repository.member_hash_warnings(self.member_id)
|
|
self.messages.set_messages(
|
|
[
|
|
TabMessage(
|
|
"warning",
|
|
"WARNUNG: " + " | ".join(warnings),
|
|
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
|
|
)
|
|
]
|
|
if warnings
|
|
else []
|
|
)
|
|
date_fields = {
|
|
"birth_date",
|
|
"application_date",
|
|
"accepted_at",
|
|
"membership_started_at",
|
|
"membership_ended_at",
|
|
"mandate_signed_at",
|
|
"mandate_revoked_at",
|
|
}
|
|
for key, variable in self.variables.items():
|
|
value = getattr(self.member, key)
|
|
if key == "status":
|
|
variable.set(display_label(STATUS_LABELS, str(value)))
|
|
elif key == "payment_frequency":
|
|
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
|
|
else:
|
|
variable.set(format_date_for_display(value) if key in date_fields else value)
|
|
if self.notes_text is not None:
|
|
self.notes_text.delete("1.0", "end")
|
|
self.notes_text.insert("1.0", self.member.notes)
|
|
self.notes_text.edit_modified(False)
|
|
self._loading = False
|
|
self._clear_dirty()
|
|
self._refresh_events()
|
|
self._refresh_contributions()
|
|
self._refresh_contribution_overrides()
|
|
self._refresh_donations()
|
|
self._refresh_assets()
|
|
self._refresh_documents()
|
|
|
|
def _refresh_events(self) -> None:
|
|
self.timeline.delete(*self.timeline.get_children())
|
|
try:
|
|
events = self.repository.get_events(self.member_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Chronik beschädigt", str(exc), parent=self)
|
|
return
|
|
for event in reversed(events):
|
|
self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event)))
|
|
|
|
def _refresh_contributions(self) -> None:
|
|
self.claims.delete(*self.claims.get_children())
|
|
self.payments.delete(*self.payments.get_children())
|
|
try:
|
|
data = self.repository.get_contributions(self.member_id)
|
|
except RepositoryError as exc:
|
|
self.contribution_summary.set(f"FEHLER: {exc}")
|
|
self.payment_summary.set(f"FEHLER: {exc}")
|
|
return
|
|
claims = sorted(
|
|
data.claims,
|
|
key=lambda claim: _claim_sort_value(data, claim, self.claim_sort_column).casefold(),
|
|
reverse=self.claim_sort_descending,
|
|
)
|
|
self._update_claim_headings()
|
|
for index, claim in enumerate(claims):
|
|
claim_id = str(claim.get("claim_id") or f"missing-id-{index}")
|
|
status = claim_status(data, claim)
|
|
self.claims.insert(
|
|
"",
|
|
"end",
|
|
iid=claim_id,
|
|
values=(
|
|
format_date_for_display(str(claim.get("due_date", ""))),
|
|
money_text(claim_total(claim)),
|
|
CLAIM_STATUS_LABELS.get(status, status.upper()),
|
|
claim.get("title", "Beitrag"),
|
|
),
|
|
)
|
|
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", ""))
|
|
amount = payment.get("amount", "0")
|
|
allocated = payment_allocated_total(data, payment_id)
|
|
available = max(Decimal(str(amount)) - allocated, Decimal("0"))
|
|
self.payments.insert(
|
|
"",
|
|
"end",
|
|
iid=payment_id,
|
|
values=(
|
|
format_date_for_display(str(payment.get("date", ""))),
|
|
f"{money_text(amount)} EUR",
|
|
f"{money_text(allocated)} EUR",
|
|
f"{money_text(available)} EUR",
|
|
payment.get("reference", ""),
|
|
),
|
|
)
|
|
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
|
|
total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
|
|
total_allocated = sum(
|
|
(payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
|
|
Decimal("0"),
|
|
)
|
|
self.payment_summary.set(
|
|
f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
|
|
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
|
|
)
|
|
|
|
def _refresh_donations(self) -> None:
|
|
self.donations.delete(*self.donations.get_children())
|
|
try:
|
|
data = self.repository.get_contributions(self.member_id)
|
|
except RepositoryError as exc:
|
|
self.donation_summary.set(f"FEHLER: {exc}")
|
|
return
|
|
for donation in sorted(
|
|
data.donations,
|
|
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
|
reverse=True,
|
|
):
|
|
donation_id = str(donation.get("donation_id", ""))
|
|
amount = donation.get("amount", "0")
|
|
allocated = donation_allocated_total(data, donation_id)
|
|
balance = donation_balance(data, donation)
|
|
status = donation_status(data, donation)
|
|
reference = " · ".join(
|
|
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
|
|
)
|
|
self.donations.insert(
|
|
"",
|
|
"end",
|
|
iid=donation_id,
|
|
values=(
|
|
format_date_for_display(str(donation.get("date", ""))),
|
|
f"{money_text(amount)} EUR",
|
|
f"{money_text(allocated)} EUR",
|
|
f"{money_text(balance)} EUR",
|
|
DONATION_STATUS_LABELS.get(status, status.upper()),
|
|
reference,
|
|
),
|
|
)
|
|
total_amount = sum(
|
|
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
|
|
)
|
|
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
|
|
|
|
def _refresh_contribution_overrides(self) -> None:
|
|
self.contribution_overrides.delete(*self.contribution_overrides.get_children())
|
|
try:
|
|
data = self.repository.get_contributions(self.member_id)
|
|
except RepositoryError:
|
|
return
|
|
for override in sorted(
|
|
data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
|
|
):
|
|
kind = str(override.get("kind", ""))
|
|
value = str(override.get("value", ""))
|
|
value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
|
|
self.contribution_overrides.insert(
|
|
"",
|
|
"end",
|
|
iid=str(override.get("override_id", "")),
|
|
values=(
|
|
format_month_for_display(str(override.get("valid_from", ""))),
|
|
format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
|
|
CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
|
|
value_text,
|
|
override.get("reason", ""),
|
|
),
|
|
)
|
|
|
|
def _toggle_claim_sort(self, column: str) -> None:
|
|
if self.claim_sort_column == column:
|
|
self.claim_sort_descending = not self.claim_sort_descending
|
|
else:
|
|
self.claim_sort_column = column
|
|
self.claim_sort_descending = False
|
|
self._refresh_contributions()
|
|
|
|
def _update_claim_headings(self) -> None:
|
|
for key, title, _width in CLAIM_TABLE_COLUMNS:
|
|
suffix = ""
|
|
if key == self.claim_sort_column:
|
|
suffix = " v" if self.claim_sort_descending else " ^"
|
|
self.claims.heading(
|
|
key,
|
|
text=f"{title}{suffix}",
|
|
command=lambda column=key: self._toggle_claim_sort(column),
|
|
)
|
|
|
|
def _open_selected_claim(self) -> None:
|
|
selected = self.claims.selection()
|
|
if selected and not selected[0].startswith("missing-id-"):
|
|
self.on_open_claim(self.member_id, selected[0])
|
|
|
|
def _edit_selected_payment(self) -> None:
|
|
selected = self.payments.selection()
|
|
if not selected:
|
|
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
|
return
|
|
try:
|
|
PaymentEditDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
selected[0],
|
|
self._payment_changed,
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)
|
|
|
|
def _delete_selected_payment(self) -> None:
|
|
selected = self.payments.selection()
|
|
if not selected:
|
|
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
|
return
|
|
values = self.payments.item(selected[0], "values")
|
|
label = f"{values[0]} · {values[1]}" if values else selected[0]
|
|
if not messagebox.askyesno(
|
|
"Zahlung wirklich löschen?",
|
|
f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
|
|
parent=self,
|
|
):
|
|
return
|
|
try:
|
|
self.repository.delete_payment(self.member_id, selected[0])
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
|
|
return
|
|
self._payment_changed()
|
|
|
|
def _create_payment(self) -> None:
|
|
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
|
|
|
|
def _import_payments(self) -> None:
|
|
GnuCashImportDialog(
|
|
self, self.repository, self.member_id, self.config, self._payment_changed
|
|
)
|
|
|
|
def _payment_changed(self) -> None:
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _selected_donation(self) -> dict | None:
|
|
selected = self.donations.selection()
|
|
if not selected:
|
|
return None
|
|
try:
|
|
_data, donation = self.repository.get_donation(self.member_id, selected[0])
|
|
except RepositoryError:
|
|
return None
|
|
return donation
|
|
|
|
def _create_donation(self) -> None:
|
|
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
|
|
|
|
def _edit_selected_donation(self) -> None:
|
|
donation = self._selected_donation()
|
|
if not donation:
|
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
|
return
|
|
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
|
|
|
|
def _delete_selected_donation(self) -> None:
|
|
donation = self._selected_donation()
|
|
if not donation:
|
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
|
return
|
|
data = self.repository.get_contributions(self.member_id)
|
|
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
|
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
|
if allocated:
|
|
detail += (
|
|
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
|
|
"gelöst und stehen danach wieder frei zur Verfügung."
|
|
)
|
|
if not messagebox.askyesno("Spende löschen", detail, parent=self):
|
|
return
|
|
try:
|
|
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
self._donation_changed()
|
|
|
|
def _record_donation_payment(self) -> None:
|
|
donation = self._selected_donation()
|
|
if not donation:
|
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
|
return
|
|
data = self.repository.get_contributions(self.member_id)
|
|
DonationPaymentDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
str(donation.get("donation_id", "")),
|
|
donation_balance(data, donation),
|
|
self._donation_changed,
|
|
)
|
|
|
|
def _allocate_donation_payment(self) -> None:
|
|
donation = self._selected_donation()
|
|
if not donation:
|
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
|
return
|
|
data = self.repository.get_contributions(self.member_id)
|
|
AllocateDonationPaymentDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
str(donation.get("donation_id", "")),
|
|
donation_balance(data, donation),
|
|
self._donation_changed,
|
|
)
|
|
|
|
def _donation_changed(self) -> None:
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _selected_contribution_override(self) -> dict | None:
|
|
selected = self.contribution_overrides.selection()
|
|
if not selected:
|
|
return None
|
|
try:
|
|
_data, override = self.repository.get_contribution_override(self.member_id, selected[0])
|
|
except RepositoryError:
|
|
return None
|
|
return override
|
|
|
|
def _create_contribution_override(self) -> None:
|
|
ContributionOverrideEditDialog(
|
|
self, self.repository, self.member_id, self._contribution_override_changed
|
|
)
|
|
|
|
def _edit_selected_contribution_override(self) -> None:
|
|
override = self._selected_contribution_override()
|
|
if not override:
|
|
messagebox.showinfo(
|
|
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
|
|
)
|
|
return
|
|
ContributionOverrideEditDialog(
|
|
self, self.repository, self.member_id, self._contribution_override_changed, override
|
|
)
|
|
|
|
def _delete_selected_contribution_override(self) -> None:
|
|
override = self._selected_contribution_override()
|
|
if not override:
|
|
messagebox.showinfo(
|
|
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
|
|
)
|
|
return
|
|
if not messagebox.askyesno(
|
|
"Abweichenden Beitrag löschen",
|
|
"Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
|
|
"davon unberührt.",
|
|
parent=self,
|
|
):
|
|
return
|
|
try:
|
|
self.repository.delete_contribution_override(
|
|
self.member_id, str(override.get("override_id", ""))
|
|
)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
self._contribution_override_changed()
|
|
|
|
def _contribution_override_changed(self) -> None:
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _refresh_documents(self) -> None:
|
|
self.documents.delete(*self.documents.get_children())
|
|
self.document_paths.clear()
|
|
root = self.repository.members_root / self.member_id / "files"
|
|
for index, path in enumerate(sorted(root.rglob("*"))):
|
|
if path.is_file():
|
|
item_id = f"document:{index}"
|
|
self.document_paths[item_id] = path
|
|
try:
|
|
stat = path.stat()
|
|
modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M")
|
|
size = _file_size(stat.st_size)
|
|
except OSError:
|
|
modified = "—"
|
|
size = "—"
|
|
self.documents.insert(
|
|
"",
|
|
"end",
|
|
iid=item_id,
|
|
values=(
|
|
path.relative_to(root),
|
|
path.suffix.removeprefix(".").upper() or "DATEI",
|
|
modified,
|
|
size,
|
|
),
|
|
)
|
|
|
|
def _refresh_assets(self) -> None:
|
|
self.assets_tree.delete(*self.assets_tree.get_children())
|
|
held_assets = self.repository.list_member_assets(self.member_id)
|
|
owned_assets = self.repository.list_member_owned_assets(self.member_id)
|
|
assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
|
|
assets = list(assets_by_id.values())
|
|
self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
|
|
for asset in assets:
|
|
self.assets_tree.insert(
|
|
"",
|
|
"end",
|
|
iid=asset.asset_id,
|
|
values=(
|
|
asset.label,
|
|
asset.category,
|
|
asset.inventory_number,
|
|
(
|
|
"EIGENTUM · DEM VEREIN ÜBERLASSEN"
|
|
if asset.owner_member_id == self.member_id and asset.custody_type == "club"
|
|
else "EIGENTUM"
|
|
if asset.owner_member_id == self.member_id
|
|
else ASSET_STATUS_LABELS.get(asset.status, asset.status)
|
|
),
|
|
),
|
|
)
|
|
|
|
def _save(self) -> None:
|
|
warnings = self.repository.member_hash_warnings(self.member_id)
|
|
if warnings:
|
|
self._confirm_integrity_and_then(self._save_confirmed)
|
|
return
|
|
self._save_confirmed()
|
|
|
|
def _save_confirmed(self) -> None:
|
|
for key, variable in self.variables.items():
|
|
raw_value = variable.get()
|
|
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
|
if key == "status":
|
|
value = storage_key(STATUS_LABELS, value)
|
|
elif key == "payment_frequency":
|
|
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
|
|
setattr(self.member, key, value)
|
|
if self.notes_text is not None:
|
|
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
|
|
try:
|
|
self.repository.save_member(self.member)
|
|
self.repository.refresh_member_record_hashes(self.member_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _confirm_integrity_banner(self) -> None:
|
|
self._confirm_integrity_and_then(self._refresh_hashes_only)
|
|
|
|
def _refresh_hashes_only(self) -> None:
|
|
self.repository.refresh_member_record_hashes(self.member_id)
|
|
self.refresh()
|
|
self.on_changed()
|
|
|
|
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
|
|
warnings = self.repository.member_hash_warnings(self.member_id)
|
|
if not warnings:
|
|
callback()
|
|
return
|
|
IntegrityWarningDialog(
|
|
self,
|
|
title="Externe Änderungen bestätigen",
|
|
warnings=warnings,
|
|
on_confirm=callback,
|
|
)
|
|
|
|
def _add_comment(self) -> None:
|
|
text = self.comment_var.get().strip()
|
|
if not text:
|
|
return
|
|
self.repository.append_event(
|
|
self.member_id,
|
|
event_type="board_comment",
|
|
summary=text,
|
|
actor_type="user",
|
|
actor_name="Vorstand",
|
|
)
|
|
self.comment_var.set("")
|
|
self._refresh_events()
|
|
|
|
def _open_files(self) -> None:
|
|
path = self.repository.members_root / self.member_id / "files"
|
|
self._open_path(path)
|
|
|
|
def _return_selected_asset(self) -> None:
|
|
selected = self.assets_tree.selection()
|
|
if selected:
|
|
self.on_return_asset(selected[0])
|
|
|
|
def _open_selected_asset(self) -> None:
|
|
selected = self.assets_tree.selection()
|
|
if selected:
|
|
self.on_open_asset(selected[0])
|
|
|
|
def _open_selected_document(self) -> None:
|
|
selected = self.documents.selection()
|
|
if selected and selected[0] in self.document_paths:
|
|
self._open_path(self.document_paths[selected[0]])
|
|
|
|
def _open_path(self, path: Path) -> None:
|
|
try:
|
|
open_path(path)
|
|
except OSError as exc:
|
|
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
|
|
|
def _create_document(self) -> None:
|
|
DocumentTemplateDialog(
|
|
self,
|
|
self.repository,
|
|
self.member_id,
|
|
self._document_generated,
|
|
)
|
|
|
|
def _document_generated(self, _path: Path) -> None:
|
|
self._refresh_documents()
|
|
self._refresh_events()
|
|
self.on_changed()
|
|
|
|
|
|
def _format_timestamp(event: Event) -> str:
|
|
try:
|
|
return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M")
|
|
except ValueError:
|
|
return event.timestamp[:16]
|
|
|
|
|
|
def _event_label(event: Event) -> str:
|
|
if event.actor_type == "system":
|
|
return f"[AUTO] {event.summary}"
|
|
return event.summary
|
|
|
|
|
|
def _file_size(size: int) -> str:
|
|
if size < 1024:
|
|
return f"{size} B"
|
|
if size < 1024 * 1024:
|
|
return f"{size / 1024:.1f} KiB"
|
|
return f"{size / (1024 * 1024):.1f} MiB"
|