mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
Update member UI and related app changes
This commit is contained in:
@@ -19,7 +19,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
self.number_policy = repository.get_member_number_policy()
|
||||
self.variables = {
|
||||
name: tk.StringVar()
|
||||
for name in ("first_name", "last_name", "email", "phone", "birth_date", "member_number")
|
||||
for name in ("first_name", "last_name", "nickname", "email", "phone", "birth_date", "member_number")
|
||||
}
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
@@ -32,6 +32,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
fields = [
|
||||
("Vorname *", "first_name"),
|
||||
("Nachname *", "last_name"),
|
||||
("Nickname", "nickname"),
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
|
||||
+58
-13
@@ -16,6 +16,27 @@ from ccma.ui.file_open import open_path
|
||||
from ccma.ui.labels import display_label, storage_key
|
||||
|
||||
|
||||
CLAIM_TABLE_COLUMNS = (
|
||||
("title", "Forderung", 220),
|
||||
("due", "Fällig", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("status", "Status", 110),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -34,6 +55,7 @@ class MemberTab(ttk.Frame):
|
||||
self.on_open_claim = on_open_claim
|
||||
self.member = repository.get_member(member_id)
|
||||
self.variables: dict[str, tk.Variable] = {}
|
||||
self.notes_text: tk.Text | None = None
|
||||
self._build_ui()
|
||||
self.refresh()
|
||||
|
||||
@@ -97,6 +119,7 @@ class MemberTab(ttk.Frame):
|
||||
("Mitgliedsnummer", "member_number"),
|
||||
("Vorname", "first_name"),
|
||||
("Nachname", "last_name"),
|
||||
("Nickname", "nickname"),
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
@@ -120,7 +143,8 @@ class MemberTab(ttk.Frame):
|
||||
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
|
||||
)
|
||||
else:
|
||||
ttk.Entry(data_tab, textvariable=variable, width=42).grid(
|
||||
entry_state = "readonly" if key == "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.variables["status"] = tk.StringVar()
|
||||
@@ -132,13 +156,11 @@ class MemberTab(ttk.Frame):
|
||||
state="readonly",
|
||||
width=39,
|
||||
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
self.variables["notes"] = tk.StringVar()
|
||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid(
|
||||
row=len(fields) + 1, column=1, sticky="ew", pady=5
|
||||
)
|
||||
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)
|
||||
data_tab.columnconfigure(1, weight=1)
|
||||
ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid(
|
||||
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
|
||||
@@ -197,13 +219,10 @@ class MemberTab(ttk.Frame):
|
||||
self.claims = ttk.Treeview(
|
||||
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
|
||||
)
|
||||
for key, title, width in (
|
||||
("title", "Forderung", 220),
|
||||
("due", "Fällig", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("status", "Status", 110),
|
||||
):
|
||||
self.claims.heading(key, text=title)
|
||||
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")
|
||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||
@@ -287,6 +306,9 @@ class MemberTab(ttk.Frame):
|
||||
variable.set(display_label(STATUS_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._refresh_events()
|
||||
self._refresh_contributions()
|
||||
self._refresh_documents()
|
||||
@@ -308,7 +330,13 @@ class MemberTab(ttk.Frame):
|
||||
except RepositoryError as exc:
|
||||
self.contribution_summary.set(f"FEHLER: {exc}")
|
||||
return
|
||||
for index, claim in enumerate(data.claims):
|
||||
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(
|
||||
@@ -324,6 +352,21 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
|
||||
|
||||
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-"):
|
||||
@@ -363,6 +406,8 @@ class MemberTab(ttk.Frame):
|
||||
if key == "status":
|
||||
value = storage_key(STATUS_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)
|
||||
except RepositoryError as exc:
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
normalize_anniversary_intervals,
|
||||
@@ -38,6 +39,11 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.anniversary_before_var = tk.StringVar(value=str(config.anniversary_days_before))
|
||||
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
|
||||
self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals)
|
||||
self.retroactive_claims_var = tk.BooleanVar(value=config.retroactive_claims)
|
||||
self.optional_member_field_vars = {
|
||||
field: tk.BooleanVar(value=field in config.optional_member_fields)
|
||||
for field in HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
}
|
||||
number_policy = repository.get_member_number_policy()
|
||||
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
||||
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
|
||||
@@ -238,6 +244,22 @@ class OptionsDialog(tk.Toplevel):
|
||||
text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=5, column=1, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
parent,
|
||||
text="Beitragsforderungen rückwirkend seit Beitritt anlegen",
|
||||
variable=self.retroactive_claims_var,
|
||||
style="Switch",
|
||||
).grid(row=6, column=0, columnspan=3, sticky="w", pady=(18, 0))
|
||||
optional_fields = ttk.LabelFrame(parent, text="Bei leeren Mitgliedsfeldern nicht meckern", padding=12)
|
||||
optional_fields.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(18, 0))
|
||||
for column in range(2):
|
||||
optional_fields.columnconfigure(column, weight=1)
|
||||
for index, (field, label) in enumerate(HOUSEKEEPER_MEMBER_FIELD_LABELS.items()):
|
||||
ttk.Checkbutton(
|
||||
optional_fields,
|
||||
text=label,
|
||||
variable=self.optional_member_field_vars[field],
|
||||
).grid(row=index // 2, column=index % 2, sticky="w", padx=(0, 16), pady=4)
|
||||
|
||||
def _build_member_numbers(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(1, weight=1)
|
||||
@@ -381,6 +403,10 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.config_obj.anniversary_days_before = anniversary_before
|
||||
self.config_obj.anniversary_days_after = anniversary_after
|
||||
self.config_obj.anniversary_intervals = anniversary_intervals
|
||||
self.config_obj.retroactive_claims = self.retroactive_claims_var.get()
|
||||
self.config_obj.optional_member_fields = tuple(
|
||||
field for field, variable in self.optional_member_field_vars.items() if variable.get()
|
||||
)
|
||||
try:
|
||||
self.config_obj.save()
|
||||
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
|
||||
|
||||
+118
-18
@@ -5,6 +5,58 @@ from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member
|
||||
from ccma.ui.labels import storage_key
|
||||
|
||||
|
||||
MEMBER_TABLE_COLUMNS = (
|
||||
("number", "Nummer", 110),
|
||||
("first_name", "Vorname", 160),
|
||||
("last_name", "Nachname", 180),
|
||||
("nickname", "Nickname", 160),
|
||||
("email", "E-Mail-Adresse", 270),
|
||||
("birth", "Geburtsdatum", 120),
|
||||
("status", "Status", 170),
|
||||
)
|
||||
|
||||
STATUS_FILTER_ALL = "Alle"
|
||||
|
||||
|
||||
def _member_table_value(member: Member, column: str) -> str:
|
||||
if column == "number":
|
||||
return member.member_number
|
||||
if column == "first_name":
|
||||
return member.first_name
|
||||
if column == "last_name":
|
||||
return member.last_name
|
||||
if column == "nickname":
|
||||
return member.nickname
|
||||
if column == "email":
|
||||
return member.email
|
||||
if column == "birth":
|
||||
return member.birth_date
|
||||
if column == "status":
|
||||
return MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)
|
||||
return ""
|
||||
|
||||
|
||||
def _filter_members(members: list[Member], status_filter: str) -> list[Member]:
|
||||
if status_filter == "all":
|
||||
return list(members)
|
||||
return [member for member in members if member.status == status_filter]
|
||||
|
||||
|
||||
def _sort_members(members: list[Member], column: str, descending: bool) -> list[Member]:
|
||||
return sorted(
|
||||
members,
|
||||
key=lambda member: _member_table_value(member, column).casefold(),
|
||||
reverse=descending,
|
||||
)
|
||||
|
||||
|
||||
def _selected_status_filter(label: str) -> str:
|
||||
if label == STATUS_FILTER_ALL:
|
||||
return "all"
|
||||
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
|
||||
|
||||
|
||||
class DashboardTab(ttk.Frame):
|
||||
@@ -82,11 +134,17 @@ class SearchResultsTab(ttk.Frame):
|
||||
ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w"
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
||||
tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings")
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2)
|
||||
tree = ttk.Treeview(
|
||||
self,
|
||||
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||
show="headings",
|
||||
)
|
||||
for key, title, width in (
|
||||
("number", "Nummer", 90),
|
||||
("name", "Name", 220),
|
||||
("first_name", "Vorname", 150),
|
||||
("last_name", "Nachname", 170),
|
||||
("nickname", "Nickname", 150),
|
||||
("email", "E-Mail-Adresse", 260),
|
||||
("birth", "Geburtsdatum", 110),
|
||||
("status", "Status", 160),
|
||||
@@ -101,7 +159,9 @@ class SearchResultsTab(ttk.Frame):
|
||||
iid=member.member_id,
|
||||
values=(
|
||||
member.member_number,
|
||||
member.display_name,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
@@ -132,7 +192,7 @@ class MembersTab(ttk.Frame):
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, 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)
|
||||
@@ -140,41 +200,81 @@ class MembersTab(ttk.Frame):
|
||||
self.count_var = tk.StringVar()
|
||||
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
||||
filters = ttk.Frame(self, padding=(12, 10))
|
||||
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
filters.columnconfigure(1, weight=1)
|
||||
ttk.Label(filters, text="Filter", style="Mono.TLabel").grid(row=0, column=0, sticky="w", padx=(0, 16))
|
||||
self.tree = ttk.Treeview(
|
||||
self, columns=("number", "name", "email", "birth", "status"), show="headings"
|
||||
self,
|
||||
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||
show="headings",
|
||||
)
|
||||
for key, title, width in (
|
||||
("number", "Nummer", 110),
|
||||
("name", "Name", 230),
|
||||
("email", "E-Mail-Adresse", 270),
|
||||
("birth", "Geburtsdatum", 120),
|
||||
("status", "Status", 170),
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.sort_column = "last_name"
|
||||
self.sort_descending = False
|
||||
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
|
||||
ttk.Label(filters, text="Status").grid(row=0, column=1, sticky="w", padx=(0, 8))
|
||||
self.status_filter = ttk.Combobox(
|
||||
filters,
|
||||
textvariable=self.status_filter_var,
|
||||
state="readonly",
|
||||
values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()],
|
||||
width=28,
|
||||
)
|
||||
self.status_filter.grid(row=0, column=2, sticky="w")
|
||||
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_members())
|
||||
for key, title, width in MEMBER_TABLE_COLUMNS:
|
||||
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
|
||||
self.tree.column(key, width=width, anchor="w")
|
||||
self.tree.grid(row=1, column=0, sticky="nsew")
|
||||
self.tree.grid(row=2, column=0, sticky="nsew")
|
||||
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
|
||||
self.tree.bind("<Return>", lambda _event: self._open_selected())
|
||||
self.refresh(self.members)
|
||||
|
||||
def refresh(self, members: list[Member]) -> None:
|
||||
self.members = members
|
||||
self._render_members()
|
||||
|
||||
def _render_members(self) -> None:
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
self.count_var.set(f"{len(members)} Mitglieder")
|
||||
for member in members:
|
||||
status_filter = _selected_status_filter(self.status_filter_var.get())
|
||||
filtered_members = _filter_members(self.members, status_filter)
|
||||
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
|
||||
if len(filtered_members) == len(self.members):
|
||||
self.count_var.set(f"{len(filtered_members)} Mitglieder")
|
||||
else:
|
||||
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
|
||||
self._update_tree_headings()
|
||||
for member in sorted_members:
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=member.member_id,
|
||||
values=(
|
||||
member.member_number,
|
||||
member.display_name,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
),
|
||||
)
|
||||
|
||||
def _toggle_sort(self, column: str) -> None:
|
||||
if self.sort_column == column:
|
||||
self.sort_descending = not self.sort_descending
|
||||
else:
|
||||
self.sort_column = column
|
||||
self.sort_descending = False
|
||||
self._render_members()
|
||||
|
||||
def _update_tree_headings(self) -> None:
|
||||
for key, title, _width in MEMBER_TABLE_COLUMNS:
|
||||
suffix = ""
|
||||
if key == self.sort_column:
|
||||
suffix = " v" if self.sort_descending else " ^"
|
||||
self.tree.heading(key, text=f"{title}{suffix}", command=lambda column=key: self._toggle_sort(column))
|
||||
|
||||
def _open_selected(self) -> None:
|
||||
selected = self.tree.selection()
|
||||
if selected:
|
||||
|
||||
Reference in New Issue
Block a user