From 30b6d253b2bb080f5731cb86d56a5564f4da04b6 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 26 Jun 2026 21:57:11 +0200 Subject: [PATCH] Update member UI and related app changes --- src/ccma/assets/themes/forest/forest-dark.tcl | 22 +-- .../assets/themes/forest/forest-light.tcl | 22 +-- src/ccma/config.py | 11 ++ src/ccma/domain/models.py | 27 ++++ src/ccma/rules/scripts/birthdate_check.py | 33 +++-- src/ccma/rules/scripts/contribution_claims.py | 3 +- src/ccma/services/documents.py | 1 + src/ccma/services/housekeeper.py | 8 +- src/ccma/storage/repository.py | 55 ++++--- src/ccma/ui/dialogs.py | 3 +- src/ccma/ui/member_tab.py | 71 +++++++-- src/ccma/ui/options_dialog.py | 26 ++++ src/ccma/ui/work_tabs.py | 136 +++++++++++++++--- tests/test_config.py | 4 + tests/test_housekeeper.py | 25 ++++ tests/test_repository.py | 6 +- tests/test_rules.py | 78 ++++++++++ tests/test_ui_imports.py | 43 ++++++ 18 files changed, 490 insertions(+), 84 deletions(-) diff --git a/src/ccma/assets/themes/forest/forest-dark.tcl b/src/ccma/assets/themes/forest/forest-dark.tcl index c8aaac2..41b4e64 100644 --- a/src/ccma/assets/themes/forest/forest-dark.tcl +++ b/src/ccma/assets/themes/forest/forest-dark.tcl @@ -311,17 +311,17 @@ namespace eval ttk::theme::forest-dark { active $I(check-unsel-hover) \ ] -width 26 -sticky w - # Switch - ttk::style element create Switch.indicator image \ - [list $I(off-accent) \ - {selected disabled} $I(on-basic) \ - disabled $I(off-basic) \ - {pressed selected} $I(on-accent) \ - {active selected} $I(on-hover) \ - selected $I(on-accent) \ - {pressed !selected} $I(off-accent) \ - active $I(off-hover) \ - ] -width 46 -sticky w + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-basic) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-hover) \ + active $I(off-hover) \ + ] -width 46 -sticky w # ToggleButton ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center diff --git a/src/ccma/assets/themes/forest/forest-light.tcl b/src/ccma/assets/themes/forest/forest-light.tcl index 1abb62d..deb1c95 100644 --- a/src/ccma/assets/themes/forest/forest-light.tcl +++ b/src/ccma/assets/themes/forest/forest-light.tcl @@ -311,17 +311,17 @@ namespace eval ttk::theme::forest-light { active $I(check-unsel-hover) \ ] -width 26 -sticky w - # Switch - ttk::style element create Switch.indicator image \ - [list $I(off-accent) \ - {selected disabled} $I(on-basic) \ - disabled $I(off-basic) \ - {pressed selected} $I(on-accent) \ - {active selected} $I(on-hover) \ - selected $I(on-accent) \ - {pressed !selected} $I(off-accent) \ - active $I(off-hover) \ - ] -width 46 -sticky w + # Switch + ttk::style element create Switch.indicator image \ + [list $I(off-basic) \ + {selected disabled} $I(on-basic) \ + disabled $I(off-basic) \ + {pressed selected} $I(on-accent) \ + {active selected} $I(on-hover) \ + selected $I(on-accent) \ + {pressed !selected} $I(off-hover) \ + active $I(off-hover) \ + ] -width 46 -sticky w # ToggleButton ttk::style configure ToggleButton -padding {8 4 8 4} -width -10 -anchor center -foregound $colors(-fg) diff --git a/src/ccma/config.py b/src/ccma/config.py index f42c726..82abf55 100644 --- a/src/ccma/config.py +++ b/src/ccma/config.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING +from ccma.domain.models import DEFAULT_OPTIONAL_MEMBER_FIELDS, normalize_optional_member_fields from ccma.storage.atomic import write_json_atomic if TYPE_CHECKING: @@ -25,6 +26,8 @@ class AppConfig: anniversary_days_before: int = 14 anniversary_days_after: int = 7 anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y" + retroactive_claims: bool = False + optional_member_fields: tuple[str, ...] = DEFAULT_OPTIONAL_MEMBER_FIELDS window_geometry: str = "" window_state: str = "normal" monitor_bounds: tuple[int, int, int, int] | None = None @@ -48,6 +51,8 @@ class AppConfig: "anniversary_days_before": self.anniversary_days_before, "anniversary_days_after": self.anniversary_days_after, "anniversary_intervals": self.anniversary_intervals, + "retroactive_claims": self.retroactive_claims, + "optional_member_fields": list(normalize_optional_member_fields(self.optional_member_fields)), "window_geometry": self.window_geometry, "window_state": self.window_state, "monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None, @@ -65,6 +70,8 @@ class AppConfig: anniversary_days_before=self.anniversary_days_before, anniversary_days_after=self.anniversary_days_after, anniversary_intervals=self.anniversary_intervals, + retroactive_claims=self.retroactive_claims, + optional_member_fields=self.optional_member_fields, ) except IntervalValidationError: return HousekeeperSettings() @@ -101,6 +108,10 @@ def load_config() -> AppConfig: anniversary_days_before=int(data.get("anniversary_days_before", 14)), anniversary_days_after=int(data.get("anniversary_days_after", 7)), anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")), + retroactive_claims=bool(data.get("retroactive_claims", False)), + optional_member_fields=normalize_optional_member_fields( + data.get("optional_member_fields", DEFAULT_OPTIONAL_MEMBER_FIELDS) + ), window_geometry=str(data.get("window_geometry", "")), window_state=str(data.get("window_state", "normal")), monitor_bounds=monitor_bounds, diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 81cef64..6449433 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -20,6 +20,30 @@ MEMBERSHIP_STATUS_LABELS = { "ended": "BEENDET", } +HOUSEKEEPER_MEMBER_FIELD_LABELS = { + "nickname": "Nickname", + "email": "E-Mail-Adresse", + "phone": "Telefonnummer", + "birth_date": "Geburtsdatum", + "street": "Straße und Hausnummer", + "postal_code": "Postleitzahl", + "city": "Ort", + "country": "Land", + "accepted_at": "Aufnahmebeschluss", + "membership_started_at": "Mitglied seit", +} + +DEFAULT_OPTIONAL_MEMBER_FIELDS = tuple( + field for field in HOUSEKEEPER_MEMBER_FIELD_LABELS if field != "birth_date" +) + + +def normalize_optional_member_fields(values: Any) -> tuple[str, ...]: + if not isinstance(values, (list, tuple, set, frozenset)): + return () + selected = {str(value).strip() for value in values} + return tuple(field for field in HOUSEKEEPER_MEMBER_FIELD_LABELS if field in selected) + @dataclass(slots=True) class Member: @@ -27,6 +51,7 @@ class Member: member_number: str first_name: str last_name: str + nickname: str = "" email: str = "" phone: str = "" birth_date: str = "" @@ -65,6 +90,7 @@ class Member: "person": { "first_name": self.first_name, "last_name": self.last_name, + "nickname": self.nickname, "birth_date": self.birth_date, "email": self.email, "phone": self.phone, @@ -113,6 +139,7 @@ class Member: member_number=str(data.get("member_number", "")), first_name=str(person.get("first_name", "")), last_name=str(person.get("last_name", "")), + nickname=str(person.get("nickname", "")), email=str(person.get("email", "")), phone=str(person.get("phone", "")), birth_date=str(person.get("birth_date", "")), diff --git a/src/ccma/rules/scripts/birthdate_check.py b/src/ccma/rules/scripts/birthdate_check.py index ab5b80b..eccba3f 100644 --- a/src/ccma/rules/scripts/birthdate_check.py +++ b/src/ccma/rules/scripts/birthdate_check.py @@ -1,3 +1,4 @@ +from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS from ccma.domain.dates import DateValidationError, validate_member_dates from ccma.rules.api import RuleContext, task @@ -7,18 +8,30 @@ ORDER = 10 def evaluate(context: RuleContext): member = context.member - if not member.birth_date.strip(): - return [ + actions = [] + optional_fields = set(getattr(context.settings, "optional_member_fields", ())) + for field, label in HOUSEKEEPER_MEMBER_FIELD_LABELS.items(): + if field in optional_fields: + continue + if str(getattr(member, field, "")).strip(): + continue + code = "missing_birth_date" if field == "birth_date" else f"missing_member_field:{field}" + detail = ( + "Das Geburtsdatum muss in der Mitgliederakte ergänzt werden." + if field == "birth_date" + else f"Das Feld {label} muss in der Mitgliederakte ergänzt werden." + ) + actions.append( task( rule_id=RULE_ID, member=member, - key_suffix="missing", + key_suffix=f"missing:{field}", severity="warning", - title=f"{member.display_name}: Geburtsdatum fehlt", - detail="Das Geburtsdatum muss in der Mitgliederakte ergänzt werden.", - code="missing_birth_date", + title=f"{member.display_name}: {label} fehlt", + detail=detail, + code=code, ) - ] + ) try: validate_member_dates( birth_date=member.birth_date, @@ -27,7 +40,7 @@ def evaluate(context: RuleContext): today=context.today, ) except DateValidationError as exc: - return [ + actions.append( task( rule_id=RULE_ID, member=member, @@ -37,5 +50,5 @@ def evaluate(context: RuleContext): detail=str(exc), code="invalid_member_dates", ) - ] - return [] + ) + return actions diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index bc5b9c3..0920fe7 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -44,7 +44,8 @@ def evaluate(context: RuleContext): ) ) - for year in (context.today.year, context.today.year + 1): + year_from = started_at.year if getattr(context.settings, "retroactive_claims", False) else context.today.year + for year in range(year_from, context.today.year + 2): actions.extend(_membership_claims(context, started_at, accepted_at, year)) return actions diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index a17bac2..af80139 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -203,6 +203,7 @@ def _template_values( "member.number": member.member_number, "member.first_name": member.first_name, "member.last_name": member.last_name, + "member.nickname": member.nickname, "member.full_name": member.display_name, "member.email": member.email, "member.phone": member.phone, diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index a10cc0f..32346fb 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any from uuid import uuid4 -from ccma.domain.models import HousekeeperFinding +from ccma.domain.models import DEFAULT_OPTIONAL_MEMBER_FIELDS, HousekeeperFinding from ccma.rules.api import RuleAction, RuleContext from ccma.rules.loader import LoadedRule, load_rules from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals @@ -29,6 +29,8 @@ class HousekeeperSettings: anniversary_intervals: tuple[AnniversaryInterval, ...] = field( default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y")) ) + retroactive_claims: bool = False + optional_member_fields: tuple[str, ...] = DEFAULT_OPTIONAL_MEMBER_FIELDS @classmethod def from_values( @@ -39,6 +41,8 @@ class HousekeeperSettings: anniversary_days_before: int, anniversary_days_after: int, anniversary_intervals: str, + retroactive_claims: bool = False, + optional_member_fields: tuple[str, ...] = (), ) -> HousekeeperSettings: return cls( birthday_days_before=min(365, max(0, birthday_days_before)), @@ -46,6 +50,8 @@ class HousekeeperSettings: anniversary_days_before=min(365, max(0, anniversary_days_before)), anniversary_days_after=min(365, max(0, anniversary_days_after)), anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)), + retroactive_claims=bool(retroactive_claims), + optional_member_fields=tuple(optional_member_fields), ) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index dc5031d..95f1d2f 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -29,6 +29,39 @@ class RepositoryError(RuntimeError): DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}" +DEFAULT_CONTRIBUTION_RULES = [ + { + "rule_id": "standard-before-2022", + "name": "Regulärer Beitrag bis 2021", + "valid_from": "1900-01-01", + "valid_until": "2021-12-31", + "annual_amount": "120.00", + "admission_fee": "10.00", + "annual_due": "01-31", + "semiannual_due": ["01-31", "07-31"], + "entry_proration": {"mode": "monthly", "started_month": "included"}, + "first_payment_due_days_after_acceptance": 28, + "issue_days_before_due": 30, + "reminder_fee": "5.00", + "failed_debit_fee": "5.00", + }, + { + "rule_id": "standard-2022", + "name": "Regulärer Beitrag ab 2022", + "valid_from": "2022-01-01", + "valid_until": None, + "annual_amount": "150.00", + "admission_fee": "10.00", + "annual_due": "01-31", + "semiannual_due": ["01-31", "07-31"], + "entry_proration": {"mode": "monthly", "started_month": "included"}, + "first_payment_due_days_after_acceptance": 28, + "issue_days_before_due": 30, + "reminder_fee": "5.00", + "failed_debit_fee": "5.00", + }, +] + DEFAULT_CONFIGURATION = { "schema_version": 1, @@ -73,23 +106,7 @@ DEFAULT_CONFIGURATION = { }, ], }, - "contribution_rules": [ - { - "rule_id": "standard-2022", - "name": "Regulärer Beitrag ab 2022", - "valid_from": "2022-01-01", - "valid_until": None, - "annual_amount": "150.00", - "admission_fee": "10.00", - "annual_due": "01-31", - "semiannual_due": ["01-31", "07-31"], - "entry_proration": {"mode": "monthly", "started_month": "included"}, - "first_payment_due_days_after_acceptance": 28, - "issue_days_before_due": 30, - "reminder_fee": "5.00", - "failed_debit_fee": "5.00", - } - ], + "contribution_rules": DEFAULT_CONTRIBUTION_RULES, } @@ -209,6 +226,7 @@ class MemberRepository: *, first_name: str, last_name: str, + nickname: str = "", email: str = "", phone: str = "", birth_date: str = "", @@ -238,6 +256,7 @@ class MemberRepository: member_number=selected_number, first_name=first_name.strip(), last_name=last_name.strip(), + nickname=nickname.strip(), email=email.strip(), phone=phone.strip(), birth_date=birth_date, @@ -752,6 +771,7 @@ class MemberRepository: member.member_number, member.first_name, member.last_name, + member.nickname, member.display_name, member.email, member.phone, @@ -880,6 +900,7 @@ class MemberRepository: "member_number": "Mitgliedsnummer", "first_name": "Vorname", "last_name": "Nachname", + "nickname": "Nickname", "email": "E-Mail-Adresse", "phone": "Telefonnummer", "birth_date": "Geburtsdatum", diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py index 7c52d3f..531fd34 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -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("", 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"), diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index ee7dd71..6bcca1c 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("", 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: diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index 7e2802b..5d6f402 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -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) diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index e843c58..06cb1d6 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -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("<>", 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("", lambda _event: self._open_selected()) self.tree.bind("", 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: diff --git a/tests/test_config.py b/tests/test_config.py index 07df4c0..05a1642 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None anniversary_days_before=21, anniversary_days_after=5, anniversary_intervals="30D;2M;1Y;10Y", + retroactive_claims=True, + optional_member_fields=("email", "phone", "nickname"), window_geometry="1200x800-1800+40", window_state="maximized", monitor_bounds=(-1920, 0, 1920, 1080), @@ -28,6 +30,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None assert raw["schema_version"] == 1 assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080] assert raw["splash_minimum_seconds"] == 0 + assert raw["retroactive_claims"] is True + assert raw["optional_member_fields"] == ["nickname", "email", "phone"] def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) -> None: diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py index c3600d0..56fe8b2 100644 --- a/tests/test_housekeeper.py +++ b/tests/test_housekeeper.py @@ -113,3 +113,28 @@ def test_housekeeper_reports_invalid_member_dates(tmp_path) -> None: assert len(invalid) == 1 assert invalid[0].member_id == member.member_id assert "Geburtsdatum" in invalid[0].detail + + +def test_housekeeper_can_treat_selected_member_fields_as_optional(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Optional", last_name="Fields") + member.status = "active" + repository.save_member(member) + settings = HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + optional_member_fields=("nickname", "email", "phone", "birth_date"), + ) + + findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + + codes = {finding.code for finding in findings} + assert "missing_birth_date" not in codes + assert "missing_member_field:nickname" not in codes + assert "missing_member_field:email" not in codes + assert "missing_member_field:phone" not in codes + assert "missing_member_field:street" in codes diff --git a/tests/test_repository.py b/tests/test_repository.py index 4ec77d1..1b92ca6 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -18,6 +18,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None: member = repository.create_member( first_name="Ada", last_name="Lovelace", + nickname="Enchantress", email="ada@example.org", birth_date="1990-12-10", member_number="0042", @@ -32,6 +33,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None: raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8")) assert raw["person"]["first_name"] == "Ada" + assert raw["person"]["nickname"] == "Enchantress" assert raw["schema_version"] == 1 @@ -41,12 +43,13 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non member = repository.create_member( first_name="Jörg", last_name="Müller", + nickname="Jogi", email="joerg.mueller@example.org", birth_date="1990-04-23", member_number="C3-007", ) - for query in ("Jorg Muller", "mueller@example.org", "C3-007", "23.04.1990"): + for query in ("Jorg Muller", "Jogi", "mueller@example.org", "C3-007", "23.04.1990"): assert [result.member_id for result in repository.search(query)] == [member.member_id] @@ -138,6 +141,7 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm first = repository.create_member(first_name="First", last_name="Member") second = repository.create_member(first_name="Second", last_name="Member") + assert first.member_number == "CCMA-0001" assert second.member_number == "CCMA-0002" assert repository.preview_member_number() == "CCMA-0003" diff --git a/tests/test_rules.py b/tests/test_rules.py index 9283f26..25a2798 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -91,6 +91,84 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None: assert state["last_completed_run"] == "2026-04-15:000002" +def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Retro", last_name="Claims", birth_date="1990-01-01") + member.status = "active" + member.accepted_at = "2024-04-15" + member.membership_started_at = "2024-04-15" + repository.save_member(member) + + settings = housekeeper_module.HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + retroactive_claims=True, + ) + + Housekeeper(repository, settings).run(today=date(2026, 6, 21)) + + claims = repository.get_contributions(member.member_id).claims + claims_by_key = {claim["claim_key"]: claim for claim in claims} + + assert set(claims_by_key) == { + "admission-fee", + "membership-fee:2024:annual", + "membership-fee:2025:annual", + "membership-fee:2026:annual", + } + assert claims_by_key["membership-fee:2024:annual"]["amount"] == "150.00" + + +def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Legacy", last_name="Rates", birth_date="1990-01-01") + member.status = "active" + member.accepted_at = "2021-04-15" + member.membership_started_at = "2021-04-15" + repository.save_member(member) + + settings = housekeeper_module.HousekeeperSettings.from_values( + birthday_days_before=0, + birthday_days_after=0, + anniversary_days_before=0, + anniversary_days_after=0, + anniversary_intervals="1Y", + retroactive_claims=True, + ) + + Housekeeper(repository, settings).run(today=date(2022, 6, 21)) + + claims = repository.get_contributions(member.member_id).claims + claims_by_key = {claim["claim_key"]: claim for claim in claims} + + assert claims_by_key["membership-fee:2021:annual"]["amount"] == "90.00" + assert claims_by_key["membership-fee:2022:annual"]["amount"] == "150.00" + + +def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Current", last_name="Only", birth_date="1990-01-01") + member.status = "active" + member.accepted_at = "2024-04-15" + member.membership_started_at = "2024-04-15" + repository.save_member(member) + + Housekeeper(repository).run(today=date(2026, 6, 21)) + + claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims} + + assert claim_keys == { + "admission-fee", + "membership-fee:2026:annual", + } + + def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index 38c1e42..42cc141 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -96,3 +96,46 @@ def test_german_ui_labels_round_trip_to_english_storage_keys() -> None: assert storage_key(CLAIM_ITEM_TYPE_LABELS, "Dienstleistung") == "service" assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV" assert storage_key(MEMBERSHIP_STATUS_LABELS, "EHRENMITGLIED") == "honorary" + + +def test_member_table_filter_only_keeps_selected_status() -> None: + from ccma.domain.models import Member + from ccma.ui.work_tabs import _filter_members, _selected_status_filter + + members = [ + Member("1", "0001", "Ada", "Lovelace", status="active"), + Member("2", "0002", "Grace", "Hopper", status="application"), + Member("3", "0003", "Linus", "Example", status="active"), + ] + + assert _selected_status_filter("Alle") == "all" + assert _selected_status_filter("AKTIV") == "active" + assert [member.member_id for member in _filter_members(members, "active")] == ["1", "3"] + assert [member.member_id for member in _filter_members(members, "all")] == ["1", "2", "3"] + + +def test_member_table_sort_uses_display_values() -> None: + from ccma.domain.models import Member + from ccma.ui.work_tabs import _sort_members + + members = [ + Member("1", "0002", "Grace", "Hopper", status="application"), + Member("2", "0001", "Ada", "Lovelace", status="active"), + Member("3", "0003", "Linus", "Example", status="honorary"), + ] + + assert [member.member_id for member in _sort_members(members, "number", False)] == ["2", "1", "3"] + assert [member.member_id for member in _sort_members(members, "first_name", False)] == ["2", "1", "3"] + assert [member.member_id for member in _sort_members(members, "last_name", False)] == ["3", "1", "2"] + assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"] + + +def test_claim_table_sort_uses_due_date_by_raw_value() -> None: + from ccma.domain.models import ContributionData + from ccma.ui.member_tab import _claim_sort_value + + data = ContributionData() + older = {"title": "Alt", "due_date": "2024-01-31", "amount": "75.00"} + newer = {"title": "Neu", "due_date": "2025-07-31", "amount": "50.00"} + + assert _claim_sort_value(data, older, "due") < _claim_sort_value(data, newer, "due")