mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
Update member UI and related app changes
This commit is contained in:
@@ -313,13 +313,13 @@ namespace eval ttk::theme::forest-dark {
|
|||||||
|
|
||||||
# Switch
|
# Switch
|
||||||
ttk::style element create Switch.indicator image \
|
ttk::style element create Switch.indicator image \
|
||||||
[list $I(off-accent) \
|
[list $I(off-basic) \
|
||||||
{selected disabled} $I(on-basic) \
|
{selected disabled} $I(on-basic) \
|
||||||
disabled $I(off-basic) \
|
disabled $I(off-basic) \
|
||||||
{pressed selected} $I(on-accent) \
|
{pressed selected} $I(on-accent) \
|
||||||
{active selected} $I(on-hover) \
|
{active selected} $I(on-hover) \
|
||||||
selected $I(on-accent) \
|
selected $I(on-accent) \
|
||||||
{pressed !selected} $I(off-accent) \
|
{pressed !selected} $I(off-hover) \
|
||||||
active $I(off-hover) \
|
active $I(off-hover) \
|
||||||
] -width 46 -sticky w
|
] -width 46 -sticky w
|
||||||
|
|
||||||
|
|||||||
@@ -313,13 +313,13 @@ namespace eval ttk::theme::forest-light {
|
|||||||
|
|
||||||
# Switch
|
# Switch
|
||||||
ttk::style element create Switch.indicator image \
|
ttk::style element create Switch.indicator image \
|
||||||
[list $I(off-accent) \
|
[list $I(off-basic) \
|
||||||
{selected disabled} $I(on-basic) \
|
{selected disabled} $I(on-basic) \
|
||||||
disabled $I(off-basic) \
|
disabled $I(off-basic) \
|
||||||
{pressed selected} $I(on-accent) \
|
{pressed selected} $I(on-accent) \
|
||||||
{active selected} $I(on-hover) \
|
{active selected} $I(on-hover) \
|
||||||
selected $I(on-accent) \
|
selected $I(on-accent) \
|
||||||
{pressed !selected} $I(off-accent) \
|
{pressed !selected} $I(off-hover) \
|
||||||
active $I(off-hover) \
|
active $I(off-hover) \
|
||||||
] -width 46 -sticky w
|
] -width 46 -sticky w
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
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
|
from ccma.storage.atomic import write_json_atomic
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -25,6 +26,8 @@ class AppConfig:
|
|||||||
anniversary_days_before: int = 14
|
anniversary_days_before: int = 14
|
||||||
anniversary_days_after: int = 7
|
anniversary_days_after: int = 7
|
||||||
anniversary_intervals: str = "1Y;5Y;10Y;25Y;50Y"
|
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_geometry: str = ""
|
||||||
window_state: str = "normal"
|
window_state: str = "normal"
|
||||||
monitor_bounds: tuple[int, int, int, int] | None = None
|
monitor_bounds: tuple[int, int, int, int] | None = None
|
||||||
@@ -48,6 +51,8 @@ class AppConfig:
|
|||||||
"anniversary_days_before": self.anniversary_days_before,
|
"anniversary_days_before": self.anniversary_days_before,
|
||||||
"anniversary_days_after": self.anniversary_days_after,
|
"anniversary_days_after": self.anniversary_days_after,
|
||||||
"anniversary_intervals": self.anniversary_intervals,
|
"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_geometry": self.window_geometry,
|
||||||
"window_state": self.window_state,
|
"window_state": self.window_state,
|
||||||
"monitor_bounds": list(self.monitor_bounds) if self.monitor_bounds else None,
|
"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_before=self.anniversary_days_before,
|
||||||
anniversary_days_after=self.anniversary_days_after,
|
anniversary_days_after=self.anniversary_days_after,
|
||||||
anniversary_intervals=self.anniversary_intervals,
|
anniversary_intervals=self.anniversary_intervals,
|
||||||
|
retroactive_claims=self.retroactive_claims,
|
||||||
|
optional_member_fields=self.optional_member_fields,
|
||||||
)
|
)
|
||||||
except IntervalValidationError:
|
except IntervalValidationError:
|
||||||
return HousekeeperSettings()
|
return HousekeeperSettings()
|
||||||
@@ -101,6 +108,10 @@ def load_config() -> AppConfig:
|
|||||||
anniversary_days_before=int(data.get("anniversary_days_before", 14)),
|
anniversary_days_before=int(data.get("anniversary_days_before", 14)),
|
||||||
anniversary_days_after=int(data.get("anniversary_days_after", 7)),
|
anniversary_days_after=int(data.get("anniversary_days_after", 7)),
|
||||||
anniversary_intervals=str(data.get("anniversary_intervals", "1Y;5Y;10Y;25Y;50Y")),
|
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_geometry=str(data.get("window_geometry", "")),
|
||||||
window_state=str(data.get("window_state", "normal")),
|
window_state=str(data.get("window_state", "normal")),
|
||||||
monitor_bounds=monitor_bounds,
|
monitor_bounds=monitor_bounds,
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ MEMBERSHIP_STATUS_LABELS = {
|
|||||||
"ended": "BEENDET",
|
"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)
|
@dataclass(slots=True)
|
||||||
class Member:
|
class Member:
|
||||||
@@ -27,6 +51,7 @@ class Member:
|
|||||||
member_number: str
|
member_number: str
|
||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
|
nickname: str = ""
|
||||||
email: str = ""
|
email: str = ""
|
||||||
phone: str = ""
|
phone: str = ""
|
||||||
birth_date: str = ""
|
birth_date: str = ""
|
||||||
@@ -65,6 +90,7 @@ class Member:
|
|||||||
"person": {
|
"person": {
|
||||||
"first_name": self.first_name,
|
"first_name": self.first_name,
|
||||||
"last_name": self.last_name,
|
"last_name": self.last_name,
|
||||||
|
"nickname": self.nickname,
|
||||||
"birth_date": self.birth_date,
|
"birth_date": self.birth_date,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"phone": self.phone,
|
"phone": self.phone,
|
||||||
@@ -113,6 +139,7 @@ class Member:
|
|||||||
member_number=str(data.get("member_number", "")),
|
member_number=str(data.get("member_number", "")),
|
||||||
first_name=str(person.get("first_name", "")),
|
first_name=str(person.get("first_name", "")),
|
||||||
last_name=str(person.get("last_name", "")),
|
last_name=str(person.get("last_name", "")),
|
||||||
|
nickname=str(person.get("nickname", "")),
|
||||||
email=str(person.get("email", "")),
|
email=str(person.get("email", "")),
|
||||||
phone=str(person.get("phone", "")),
|
phone=str(person.get("phone", "")),
|
||||||
birth_date=str(person.get("birth_date", "")),
|
birth_date=str(person.get("birth_date", "")),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||||
from ccma.domain.dates import DateValidationError, validate_member_dates
|
from ccma.domain.dates import DateValidationError, validate_member_dates
|
||||||
from ccma.rules.api import RuleContext, task
|
from ccma.rules.api import RuleContext, task
|
||||||
|
|
||||||
@@ -7,18 +8,30 @@ ORDER = 10
|
|||||||
|
|
||||||
def evaluate(context: RuleContext):
|
def evaluate(context: RuleContext):
|
||||||
member = context.member
|
member = context.member
|
||||||
if not member.birth_date.strip():
|
actions = []
|
||||||
return [
|
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(
|
task(
|
||||||
rule_id=RULE_ID,
|
rule_id=RULE_ID,
|
||||||
member=member,
|
member=member,
|
||||||
key_suffix="missing",
|
key_suffix=f"missing:{field}",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
title=f"{member.display_name}: Geburtsdatum fehlt",
|
title=f"{member.display_name}: {label} fehlt",
|
||||||
detail="Das Geburtsdatum muss in der Mitgliederakte ergänzt werden.",
|
detail=detail,
|
||||||
code="missing_birth_date",
|
code=code,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
validate_member_dates(
|
validate_member_dates(
|
||||||
birth_date=member.birth_date,
|
birth_date=member.birth_date,
|
||||||
@@ -27,7 +40,7 @@ def evaluate(context: RuleContext):
|
|||||||
today=context.today,
|
today=context.today,
|
||||||
)
|
)
|
||||||
except DateValidationError as exc:
|
except DateValidationError as exc:
|
||||||
return [
|
actions.append(
|
||||||
task(
|
task(
|
||||||
rule_id=RULE_ID,
|
rule_id=RULE_ID,
|
||||||
member=member,
|
member=member,
|
||||||
@@ -37,5 +50,5 @@ def evaluate(context: RuleContext):
|
|||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
code="invalid_member_dates",
|
code="invalid_member_dates",
|
||||||
)
|
)
|
||||||
]
|
)
|
||||||
return []
|
return actions
|
||||||
|
|||||||
@@ -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))
|
actions.extend(_membership_claims(context, started_at, accepted_at, year))
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ def _template_values(
|
|||||||
"member.number": member.member_number,
|
"member.number": member.member_number,
|
||||||
"member.first_name": member.first_name,
|
"member.first_name": member.first_name,
|
||||||
"member.last_name": member.last_name,
|
"member.last_name": member.last_name,
|
||||||
|
"member.nickname": member.nickname,
|
||||||
"member.full_name": member.display_name,
|
"member.full_name": member.display_name,
|
||||||
"member.email": member.email,
|
"member.email": member.email,
|
||||||
"member.phone": member.phone,
|
"member.phone": member.phone,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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.api import RuleAction, RuleContext
|
||||||
from ccma.rules.loader import LoadedRule, load_rules
|
from ccma.rules.loader import LoadedRule, load_rules
|
||||||
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
|
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
|
||||||
@@ -29,6 +29,8 @@ class HousekeeperSettings:
|
|||||||
anniversary_intervals: tuple[AnniversaryInterval, ...] = field(
|
anniversary_intervals: tuple[AnniversaryInterval, ...] = field(
|
||||||
default_factory=lambda: tuple(parse_anniversary_intervals("1Y;5Y;10Y;25Y;50Y"))
|
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
|
@classmethod
|
||||||
def from_values(
|
def from_values(
|
||||||
@@ -39,6 +41,8 @@ class HousekeeperSettings:
|
|||||||
anniversary_days_before: int,
|
anniversary_days_before: int,
|
||||||
anniversary_days_after: int,
|
anniversary_days_after: int,
|
||||||
anniversary_intervals: str,
|
anniversary_intervals: str,
|
||||||
|
retroactive_claims: bool = False,
|
||||||
|
optional_member_fields: tuple[str, ...] = (),
|
||||||
) -> HousekeeperSettings:
|
) -> HousekeeperSettings:
|
||||||
return cls(
|
return cls(
|
||||||
birthday_days_before=min(365, max(0, birthday_days_before)),
|
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_before=min(365, max(0, anniversary_days_before)),
|
||||||
anniversary_days_after=min(365, max(0, anniversary_days_after)),
|
anniversary_days_after=min(365, max(0, anniversary_days_after)),
|
||||||
anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)),
|
anniversary_intervals=tuple(parse_anniversary_intervals(anniversary_intervals)),
|
||||||
|
retroactive_claims=bool(retroactive_claims),
|
||||||
|
optional_member_fields=tuple(optional_member_fields),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,39 @@ class RepositoryError(RuntimeError):
|
|||||||
|
|
||||||
DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
|
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 = {
|
DEFAULT_CONFIGURATION = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
@@ -73,23 +106,7 @@ DEFAULT_CONFIGURATION = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"contribution_rules": [
|
"contribution_rules": DEFAULT_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",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +226,7 @@ class MemberRepository:
|
|||||||
*,
|
*,
|
||||||
first_name: str,
|
first_name: str,
|
||||||
last_name: str,
|
last_name: str,
|
||||||
|
nickname: str = "",
|
||||||
email: str = "",
|
email: str = "",
|
||||||
phone: str = "",
|
phone: str = "",
|
||||||
birth_date: str = "",
|
birth_date: str = "",
|
||||||
@@ -238,6 +256,7 @@ class MemberRepository:
|
|||||||
member_number=selected_number,
|
member_number=selected_number,
|
||||||
first_name=first_name.strip(),
|
first_name=first_name.strip(),
|
||||||
last_name=last_name.strip(),
|
last_name=last_name.strip(),
|
||||||
|
nickname=nickname.strip(),
|
||||||
email=email.strip(),
|
email=email.strip(),
|
||||||
phone=phone.strip(),
|
phone=phone.strip(),
|
||||||
birth_date=birth_date,
|
birth_date=birth_date,
|
||||||
@@ -752,6 +771,7 @@ class MemberRepository:
|
|||||||
member.member_number,
|
member.member_number,
|
||||||
member.first_name,
|
member.first_name,
|
||||||
member.last_name,
|
member.last_name,
|
||||||
|
member.nickname,
|
||||||
member.display_name,
|
member.display_name,
|
||||||
member.email,
|
member.email,
|
||||||
member.phone,
|
member.phone,
|
||||||
@@ -880,6 +900,7 @@ class MemberRepository:
|
|||||||
"member_number": "Mitgliedsnummer",
|
"member_number": "Mitgliedsnummer",
|
||||||
"first_name": "Vorname",
|
"first_name": "Vorname",
|
||||||
"last_name": "Nachname",
|
"last_name": "Nachname",
|
||||||
|
"nickname": "Nickname",
|
||||||
"email": "E-Mail-Adresse",
|
"email": "E-Mail-Adresse",
|
||||||
"phone": "Telefonnummer",
|
"phone": "Telefonnummer",
|
||||||
"birth_date": "Geburtsdatum",
|
"birth_date": "Geburtsdatum",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class NewMemberDialog(tk.Toplevel):
|
|||||||
self.number_policy = repository.get_member_number_policy()
|
self.number_policy = repository.get_member_number_policy()
|
||||||
self.variables = {
|
self.variables = {
|
||||||
name: tk.StringVar()
|
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._build_ui()
|
||||||
self.bind("<Escape>", lambda _event: self.destroy())
|
self.bind("<Escape>", lambda _event: self.destroy())
|
||||||
@@ -32,6 +32,7 @@ class NewMemberDialog(tk.Toplevel):
|
|||||||
fields = [
|
fields = [
|
||||||
("Vorname *", "first_name"),
|
("Vorname *", "first_name"),
|
||||||
("Nachname *", "last_name"),
|
("Nachname *", "last_name"),
|
||||||
|
("Nickname", "nickname"),
|
||||||
("E-Mail-Adresse", "email"),
|
("E-Mail-Adresse", "email"),
|
||||||
("Telefonnummer", "phone"),
|
("Telefonnummer", "phone"),
|
||||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
(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
|
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):
|
class MemberTab(ttk.Frame):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -34,6 +55,7 @@ class MemberTab(ttk.Frame):
|
|||||||
self.on_open_claim = on_open_claim
|
self.on_open_claim = on_open_claim
|
||||||
self.member = repository.get_member(member_id)
|
self.member = repository.get_member(member_id)
|
||||||
self.variables: dict[str, tk.Variable] = {}
|
self.variables: dict[str, tk.Variable] = {}
|
||||||
|
self.notes_text: tk.Text | None = None
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -97,6 +119,7 @@ class MemberTab(ttk.Frame):
|
|||||||
("Mitgliedsnummer", "member_number"),
|
("Mitgliedsnummer", "member_number"),
|
||||||
("Vorname", "first_name"),
|
("Vorname", "first_name"),
|
||||||
("Nachname", "last_name"),
|
("Nachname", "last_name"),
|
||||||
|
("Nickname", "nickname"),
|
||||||
("E-Mail-Adresse", "email"),
|
("E-Mail-Adresse", "email"),
|
||||||
("Telefonnummer", "phone"),
|
("Telefonnummer", "phone"),
|
||||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
(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()))
|
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
|
||||||
)
|
)
|
||||||
else:
|
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
|
row=row, column=1, sticky="ew", pady=5
|
||||||
)
|
)
|
||||||
self.variables["status"] = tk.StringVar()
|
self.variables["status"] = tk.StringVar()
|
||||||
@@ -132,13 +156,11 @@ class MemberTab(ttk.Frame):
|
|||||||
state="readonly",
|
state="readonly",
|
||||||
width=39,
|
width=39,
|
||||||
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||||
self.variables["notes"] = tk.StringVar()
|
|
||||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||||
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
||||||
)
|
)
|
||||||
ttk.Entry(data_tab, textvariable=self.variables["notes"]).grid(
|
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
||||||
row=len(fields) + 1, column=1, sticky="ew", pady=5
|
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
|
||||||
)
|
|
||||||
data_tab.columnconfigure(1, weight=1)
|
data_tab.columnconfigure(1, weight=1)
|
||||||
ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid(
|
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)
|
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
|
||||||
@@ -197,13 +219,10 @@ class MemberTab(ttk.Frame):
|
|||||||
self.claims = ttk.Treeview(
|
self.claims = ttk.Treeview(
|
||||||
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
|
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
|
||||||
)
|
)
|
||||||
for key, title, width in (
|
self.claim_sort_column = "due"
|
||||||
("title", "Forderung", 220),
|
self.claim_sort_descending = False
|
||||||
("due", "Fällig", 100),
|
for key, title, width in CLAIM_TABLE_COLUMNS:
|
||||||
("amount", "Betrag", 90),
|
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
|
||||||
("status", "Status", 110),
|
|
||||||
):
|
|
||||||
self.claims.heading(key, text=title)
|
|
||||||
self.claims.column(key, width=width, anchor="w")
|
self.claims.column(key, width=width, anchor="w")
|
||||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
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)))
|
variable.set(display_label(STATUS_LABELS, str(value)))
|
||||||
else:
|
else:
|
||||||
variable.set(format_date_for_display(value) if key in date_fields else value)
|
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_events()
|
||||||
self._refresh_contributions()
|
self._refresh_contributions()
|
||||||
self._refresh_documents()
|
self._refresh_documents()
|
||||||
@@ -308,7 +330,13 @@ class MemberTab(ttk.Frame):
|
|||||||
except RepositoryError as exc:
|
except RepositoryError as exc:
|
||||||
self.contribution_summary.set(f"FEHLER: {exc}")
|
self.contribution_summary.set(f"FEHLER: {exc}")
|
||||||
return
|
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}")
|
claim_id = str(claim.get("claim_id") or f"missing-id-{index}")
|
||||||
status = claim_status(data, claim)
|
status = claim_status(data, claim)
|
||||||
self.claims.insert(
|
self.claims.insert(
|
||||||
@@ -324,6 +352,21 @@ class MemberTab(ttk.Frame):
|
|||||||
)
|
)
|
||||||
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
|
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:
|
def _open_selected_claim(self) -> None:
|
||||||
selected = self.claims.selection()
|
selected = self.claims.selection()
|
||||||
if selected and not selected[0].startswith("missing-id-"):
|
if selected and not selected[0].startswith("missing-id-"):
|
||||||
@@ -363,6 +406,8 @@ class MemberTab(ttk.Frame):
|
|||||||
if key == "status":
|
if key == "status":
|
||||||
value = storage_key(STATUS_LABELS, value)
|
value = storage_key(STATUS_LABELS, value)
|
||||||
setattr(self.member, key, 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:
|
try:
|
||||||
self.repository.save_member(self.member)
|
self.repository.save_member(self.member)
|
||||||
except RepositoryError as exc:
|
except RepositoryError as exc:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
from ccma.config import AppConfig
|
from ccma.config import AppConfig
|
||||||
|
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||||
from ccma.services.intervals import (
|
from ccma.services.intervals import (
|
||||||
IntervalValidationError,
|
IntervalValidationError,
|
||||||
normalize_anniversary_intervals,
|
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_before_var = tk.StringVar(value=str(config.anniversary_days_before))
|
||||||
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
|
self.anniversary_after_var = tk.StringVar(value=str(config.anniversary_days_after))
|
||||||
self.anniversary_intervals_var = tk.StringVar(value=config.anniversary_intervals)
|
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()
|
number_policy = repository.get_member_number_policy()
|
||||||
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
||||||
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
|
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.",
|
text="Komma oder Semikolon; ohne Einheit = Jahre. Beispiel: 30D;2M;1;10Y.",
|
||||||
style="Muted.TLabel",
|
style="Muted.TLabel",
|
||||||
).grid(row=5, column=1, sticky="w")
|
).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:
|
def _build_member_numbers(self, parent: ttk.Frame) -> None:
|
||||||
parent.columnconfigure(1, weight=1)
|
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_before = anniversary_before
|
||||||
self.config_obj.anniversary_days_after = anniversary_after
|
self.config_obj.anniversary_days_after = anniversary_after
|
||||||
self.config_obj.anniversary_intervals = anniversary_intervals
|
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:
|
try:
|
||||||
self.config_obj.save()
|
self.config_obj.save()
|
||||||
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
|
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.dates import format_date_for_display
|
||||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member
|
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):
|
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(
|
ttk.Label(header, text=f"{len(self.members)} Treffer", style="Mono.TLabel").grid(
|
||||||
row=1, column=0, sticky="w"
|
row=1, column=0, sticky="w"
|
||||||
)
|
)
|
||||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
|
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=3, rowspan=2)
|
||||||
tree = ttk.Treeview(self, columns=("number", "name", "email", "birth", "status"), show="headings")
|
tree = ttk.Treeview(
|
||||||
|
self,
|
||||||
|
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||||
|
show="headings",
|
||||||
|
)
|
||||||
for key, title, width in (
|
for key, title, width in (
|
||||||
("number", "Nummer", 90),
|
("number", "Nummer", 90),
|
||||||
("name", "Name", 220),
|
("first_name", "Vorname", 150),
|
||||||
|
("last_name", "Nachname", 170),
|
||||||
|
("nickname", "Nickname", 150),
|
||||||
("email", "E-Mail-Adresse", 260),
|
("email", "E-Mail-Adresse", 260),
|
||||||
("birth", "Geburtsdatum", 110),
|
("birth", "Geburtsdatum", 110),
|
||||||
("status", "Status", 160),
|
("status", "Status", 160),
|
||||||
@@ -101,7 +159,9 @@ class SearchResultsTab(ttk.Frame):
|
|||||||
iid=member.member_id,
|
iid=member.member_id,
|
||||||
values=(
|
values=(
|
||||||
member.member_number,
|
member.member_number,
|
||||||
member.display_name,
|
member.first_name,
|
||||||
|
member.last_name,
|
||||||
|
member.nickname,
|
||||||
member.email,
|
member.email,
|
||||||
format_date_for_display(member.birth_date),
|
format_date_for_display(member.birth_date),
|
||||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||||
@@ -132,7 +192,7 @@ class MembersTab(ttk.Frame):
|
|||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
self.columnconfigure(0, weight=1)
|
self.columnconfigure(0, weight=1)
|
||||||
self.rowconfigure(1, weight=1)
|
self.rowconfigure(2, weight=1)
|
||||||
header = ttk.Frame(self)
|
header = ttk.Frame(self)
|
||||||
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
|
||||||
header.columnconfigure(0, weight=1)
|
header.columnconfigure(0, weight=1)
|
||||||
@@ -140,41 +200,81 @@ class MembersTab(ttk.Frame):
|
|||||||
self.count_var = tk.StringVar()
|
self.count_var = tk.StringVar()
|
||||||
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
|
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)
|
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.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 (
|
self.sort_column = "last_name"
|
||||||
("number", "Nummer", 110),
|
self.sort_descending = False
|
||||||
("name", "Name", 230),
|
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
|
||||||
("email", "E-Mail-Adresse", 270),
|
ttk.Label(filters, text="Status").grid(row=0, column=1, sticky="w", padx=(0, 8))
|
||||||
("birth", "Geburtsdatum", 120),
|
self.status_filter = ttk.Combobox(
|
||||||
("status", "Status", 170),
|
filters,
|
||||||
):
|
textvariable=self.status_filter_var,
|
||||||
self.tree.heading(key, text=title)
|
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.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("<Double-1>", lambda _event: self._open_selected())
|
||||||
self.tree.bind("<Return>", lambda _event: self._open_selected())
|
self.tree.bind("<Return>", lambda _event: self._open_selected())
|
||||||
self.refresh(self.members)
|
self.refresh(self.members)
|
||||||
|
|
||||||
def refresh(self, members: list[Member]) -> None:
|
def refresh(self, members: list[Member]) -> None:
|
||||||
self.members = members
|
self.members = members
|
||||||
|
self._render_members()
|
||||||
|
|
||||||
|
def _render_members(self) -> None:
|
||||||
self.tree.delete(*self.tree.get_children())
|
self.tree.delete(*self.tree.get_children())
|
||||||
self.count_var.set(f"{len(members)} Mitglieder")
|
status_filter = _selected_status_filter(self.status_filter_var.get())
|
||||||
for member in members:
|
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(
|
self.tree.insert(
|
||||||
"",
|
"",
|
||||||
"end",
|
"end",
|
||||||
iid=member.member_id,
|
iid=member.member_id,
|
||||||
values=(
|
values=(
|
||||||
member.member_number,
|
member.member_number,
|
||||||
member.display_name,
|
member.first_name,
|
||||||
|
member.last_name,
|
||||||
|
member.nickname,
|
||||||
member.email,
|
member.email,
|
||||||
format_date_for_display(member.birth_date),
|
format_date_for_display(member.birth_date),
|
||||||
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
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:
|
def _open_selected(self) -> None:
|
||||||
selected = self.tree.selection()
|
selected = self.tree.selection()
|
||||||
if selected:
|
if selected:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None
|
|||||||
anniversary_days_before=21,
|
anniversary_days_before=21,
|
||||||
anniversary_days_after=5,
|
anniversary_days_after=5,
|
||||||
anniversary_intervals="30D;2M;1Y;10Y",
|
anniversary_intervals="30D;2M;1Y;10Y",
|
||||||
|
retroactive_claims=True,
|
||||||
|
optional_member_fields=("email", "phone", "nickname"),
|
||||||
window_geometry="1200x800-1800+40",
|
window_geometry="1200x800-1800+40",
|
||||||
window_state="maximized",
|
window_state="maximized",
|
||||||
monitor_bounds=(-1920, 0, 1920, 1080),
|
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["schema_version"] == 1
|
||||||
assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080]
|
assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080]
|
||||||
assert raw["splash_minimum_seconds"] == 0
|
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:
|
def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -113,3 +113,28 @@ def test_housekeeper_reports_invalid_member_dates(tmp_path) -> None:
|
|||||||
assert len(invalid) == 1
|
assert len(invalid) == 1
|
||||||
assert invalid[0].member_id == member.member_id
|
assert invalid[0].member_id == member.member_id
|
||||||
assert "Geburtsdatum" in invalid[0].detail
|
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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
|||||||
member = repository.create_member(
|
member = repository.create_member(
|
||||||
first_name="Ada",
|
first_name="Ada",
|
||||||
last_name="Lovelace",
|
last_name="Lovelace",
|
||||||
|
nickname="Enchantress",
|
||||||
email="ada@example.org",
|
email="ada@example.org",
|
||||||
birth_date="1990-12-10",
|
birth_date="1990-12-10",
|
||||||
member_number="0042",
|
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"))
|
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
|
||||||
assert raw["person"]["first_name"] == "Ada"
|
assert raw["person"]["first_name"] == "Ada"
|
||||||
|
assert raw["person"]["nickname"] == "Enchantress"
|
||||||
assert raw["schema_version"] == 1
|
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(
|
member = repository.create_member(
|
||||||
first_name="Jörg",
|
first_name="Jörg",
|
||||||
last_name="Müller",
|
last_name="Müller",
|
||||||
|
nickname="Jogi",
|
||||||
email="joerg.mueller@example.org",
|
email="joerg.mueller@example.org",
|
||||||
birth_date="1990-04-23",
|
birth_date="1990-04-23",
|
||||||
member_number="C3-007",
|
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]
|
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")
|
first = repository.create_member(first_name="First", last_name="Member")
|
||||||
second = repository.create_member(first_name="Second", last_name="Member")
|
second = repository.create_member(first_name="Second", last_name="Member")
|
||||||
|
|
||||||
|
|
||||||
assert first.member_number == "CCMA-0001"
|
assert first.member_number == "CCMA-0001"
|
||||||
assert second.member_number == "CCMA-0002"
|
assert second.member_number == "CCMA-0002"
|
||||||
assert repository.preview_member_number() == "CCMA-0003"
|
assert repository.preview_member_number() == "CCMA-0003"
|
||||||
|
|||||||
@@ -91,6 +91,84 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
|
|||||||
assert state["last_completed_run"] == "2026-04-15:000002"
|
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:
|
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
|
|||||||
@@ -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 storage_key(CLAIM_ITEM_TYPE_LABELS, "Dienstleistung") == "service"
|
||||||
assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV"
|
assert display_label(MEMBERSHIP_STATUS_LABELS, "active") == "AKTIV"
|
||||||
assert storage_key(MEMBERSHIP_STATUS_LABELS, "EHRENMITGLIED") == "honorary"
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user