mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
Members are sometimes given a fee that deviates from the regular schedule -- e.g. a reduced rate for students -- for a specific period. Add a per-member "Beitrag" tab where such deviations can be recorded with a month-granular date range (Ab/Bis), a mandatory reason, and either a fixed annual amount or a percentage discount off whichever base rate is in effect at the time. Data model: ContributionData gets a contribution_overrides list, each entry validated (month format, Bis >= Ab, non-overlapping ranges per member, reason required) and CRUD'd through the repository (record/update/delete/get_contribution_override), consistent with how donations already work. Integration: contribution_claims.py now computes each membership-fee claim's amount month by month instead of a single rate for the whole billing period, picking up whichever override (if any) covers each individual month. That handles an override starting or ending mid period correctly (e.g. a semiannual payer whose discount begins in March) without changing behavior for members without overrides. Already-created claims are never recalculated retroactively, matching how changes to the global contribution rates already behave. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1144 lines
48 KiB
Python
1144 lines
48 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
|
|
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 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],
|
|
):
|
|
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.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)
|
|
)
|
|
ttk.Button(header, text="Tab schließen", command=self._close).grid(
|
|
row=0, column=1, sticky="ne", padx=(12, 0)
|
|
)
|
|
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 _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"
|