diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index e56ddcd..48eae48 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -1,16 +1,17 @@ [ { - "version": "0.0.1-dev0", - "date": "2026-06-21", + "version": "1.0.0", + "date": "2026-06-27", "changes": [ - "Erste Entwicklungsversion der dateibasierten CCMA-Mitgliederverwaltung.", - "Mitgliederakten mit Stammdaten, Anschrift, Bank-/SEPA-Daten, Dokumentordner und nachvollziehbarer Chronik.", - "Dashboard, Freitextsuche, Mitgliederliste und parallele Arbeits-Tabs in einer deutschen Light-/Dark-Oberfläche.", - "Konfigurierbare Mitgliedsnummern, validierte Datums- und Bankdaten sowie zentrale Vereins- und Absenderangaben.", - "Regelbasierter Hausmeister für Datenprüfung, Geburtstage, Jubiläen, Forderungen und anstehende Aufgaben.", - "Forderungsmanagement mit Positionen, Teilzahlungen, GnuCash-Referenzen, Gebühren und mehrstufigem Mahnworkflow.", - "OpenDocument-Templates für Mitglieder, Forderungen und Mahnungen mit Platzhaltern, Tabellenzeilen und lokaler PDF-Erzeugung.", - "Transparente JSON-Speicherung, atomare Schreibvorgänge und portable Mitgliedsordner für Backup und DSGVO-Auskunft." + "First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.", + "Mitglieder können mit Stammdaten, Anschrift, Kontaktangaben, Status, Bank-/SEPA-Daten und internen Notizen verwaltet werden.", + "Mitgliederlisten, Suche und parallele Arbeits-Tabs helfen beim schnellen Finden und Bearbeiten von Akten.", + "Assets und Inventar können angelegt, Mitgliedern zugeordnet, zurückgenommen und mit Kautionen oder Forderungen verbunden werden.", + "Forderungen, Zahlungen, Gutschriften, Mahnungen und GnuCash-Referenzen können pro Mitglied nachvollziehbar gepflegt werden.", + "Dokumente lassen sich aus OpenDocument-Vorlagen erzeugen und als PDF in den jeweiligen Akten ablegen.", + "Chroniken halten wichtige Ereignisse, Kommentare und automatisch erzeugte Vorgänge nachvollziehbar fest.", + "Der Hausmeister prüft Daten, erinnert an Aufgaben und meldet mögliche Probleme wie beschädigte oder extern geänderte Akten.", + "Alle Daten bleiben als transparente JSON-Dateien im lokalen Store lesbar und können ohne Datenbank gesichert oder geprüft werden." ] } ] 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/contributions.py b/src/ccma/domain/contributions.py index 140df2a..4aab558 100644 --- a/src/ccma/domain/contributions.py +++ b/src/ccma/domain/contributions.py @@ -14,6 +14,7 @@ CLAIM_STATUS_LABELS = { "paid": "BEZAHLT", "overpaid": "ÜBERZAHLT", "overdue": "ÜBERFÄLLIG", + "credit": "GUTSCHRIFT", "cancelled": "STORNIERT", } @@ -57,10 +58,17 @@ def claim_total(claim: dict[str, Any]) -> Decimal: return sum((decimal_value(item.get("amount", "0")) for item in claim_items(claim)), Decimal("0")) +def allocation_effect(data: ContributionData, allocation: dict[str, Any]) -> Decimal: + amount = decimal_value(allocation.get("amount", "0")) + if str(allocation.get("credit_id", "")): + return -amount + return amount + + def allocated_total(data: ContributionData, claim_id: str) -> Decimal: return sum( ( - decimal_value(allocation.get("amount", "0")) + allocation_effect(data, allocation) for allocation in data.allocations if str(allocation.get("claim_id", "")) == claim_id ), @@ -68,6 +76,13 @@ def allocated_total(data: ContributionData, claim_id: str) -> Decimal: ) +def claim_settled_total(data: ContributionData, claim: dict[str, Any]) -> Decimal: + allocated = allocated_total(data, str(claim.get("claim_id", ""))) + if claim_total(claim) < 0: + return abs(allocated).quantize(CENT) + return allocated.quantize(CENT) + + def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal: return sum( ( @@ -79,6 +94,17 @@ def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal: ) +def credit_allocated_total(data: ContributionData, credit_id: str) -> Decimal: + return sum( + ( + decimal_value(allocation.get("amount", "0")) + for allocation in data.allocations + if str(allocation.get("credit_id", "")) == credit_id + ), + Decimal("0"), + ) + + def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal: return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT) @@ -89,6 +115,8 @@ def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | total = claim_total(claim) paid = allocated_total(data, str(claim.get("claim_id", ""))) balance = total - paid + if total < 0: + return "credit" if balance < 0: return "overpaid" if balance == 0: diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 81cef64..ea4b926 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -20,6 +20,37 @@ MEMBERSHIP_STATUS_LABELS = { "ended": "BEENDET", } +ASSET_STATUS_LABELS = { + "available": "VERFUEGBAR", + "issued": "AUSGEGEBEN", + "lost": "VERLOREN", + "retired": "AUSGEMUSTERT", +} + +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 +58,7 @@ class Member: member_number: str first_name: str last_name: str + nickname: str = "" email: str = "" phone: str = "" birth_date: str = "" @@ -65,6 +97,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 +146,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", "")), @@ -140,6 +174,55 @@ class Member: ) +@dataclass(slots=True) +class Asset: + asset_id: str + label: str + category: str = "" + inventory_number: str = "" + serial_number: str = "" + status: str = "available" + current_holder_member_id: str = "" + deposit_amount_default: str = "0.00" + notes: str = "" + created_at: str = field(default_factory=_iso_now) + updated_at: str = field(default_factory=_iso_now) + schema_version: int = 1 + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "asset_id": self.asset_id, + "label": self.label, + "category": self.category, + "inventory_number": self.inventory_number, + "serial_number": self.serial_number, + "status": self.status, + "current_holder_member_id": self.current_holder_member_id, + "deposit_amount_default": self.deposit_amount_default, + "notes": self.notes, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Asset: + return cls( + schema_version=int(data.get("schema_version", 1)), + asset_id=str(data["asset_id"]), + label=str(data.get("label", "")), + category=str(data.get("category", "")), + inventory_number=str(data.get("inventory_number", "")), + serial_number=str(data.get("serial_number", "")), + status=str(data.get("status", "available")), + current_holder_member_id=str(data.get("current_holder_member_id", "")), + deposit_amount_default=str(data.get("deposit_amount_default", "0.00")), + notes=str(data.get("notes", "")), + created_at=str(data.get("created_at", _iso_now())), + updated_at=str(data.get("updated_at", _iso_now())), + ) + + @dataclass(slots=True) class Event: event_id: str @@ -182,6 +265,7 @@ class Event: class ContributionData: claims: list[dict[str, Any]] = field(default_factory=list) payments: list[dict[str, Any]] = field(default_factory=list) + credits: list[dict[str, Any]] = field(default_factory=list) allocations: list[dict[str, Any]] = field(default_factory=list) reminders: list[dict[str, Any]] = field(default_factory=list) schema_version: int = 1 @@ -191,6 +275,7 @@ class ContributionData: "schema_version": self.schema_version, "claims": self.claims, "payments": self.payments, + "credits": self.credits, "allocations": self.allocations, "reminders": self.reminders, } @@ -201,6 +286,7 @@ class ContributionData: schema_version=int(data.get("schema_version", 1)), claims=list(data.get("claims") or []), payments=list(data.get("payments") or []), + credits=list(data.get("credits") or []), allocations=list(data.get("allocations") or []), reminders=list(data.get("reminders") or []), ) @@ -209,10 +295,12 @@ class ContributionData: @dataclass(frozen=True, slots=True) class HousekeeperFinding: severity: str - member_id: str code: str title: str detail: str + member_id: str = "" + asset_id: str = "" + target_type: str = "member" due_date: date | None = None key: str = "" diff --git a/src/ccma/rules/scripts/birthdate_check.py b/src/ccma/rules/scripts/birthdate_check.py index ab5b80b..09674f4 100644 --- a/src/ccma/rules/scripts/birthdate_check.py +++ b/src/ccma/rules/scripts/birthdate_check.py @@ -1,4 +1,5 @@ from ccma.domain.dates import DateValidationError, validate_member_dates +from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS from ccma.rules.api import RuleContext, task RULE_ID = "birthdate-check" @@ -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..cb4f312 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -44,7 +44,12 @@ 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 @@ -77,6 +82,8 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date actions = [] monthly_amount = annual_amount / Decimal(12) for suffix, first_month, last_month, regular_due in periods: + # The entry year is intentionally billed from the entry month onward, + # even when retroactive claims create old membership-fee claims. charged_from = max(first_month, period_start.month) months = max(0, last_month - charged_from + 1) if months == 0: diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index a17bac2..31733e1 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -15,9 +15,9 @@ from xml.etree import ElementTree from ccma.domain.contributions import ( CLAIM_STATUS_LABELS, - allocated_total, claim_balance, claim_items, + claim_settled_total, claim_status, claim_total, money_text, @@ -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, @@ -256,7 +257,7 @@ def _template_values( ), "claim.created_at": _display_timestamp(str(claim.get("created_at", ""))), "claim.total": f"{money_text(claim_total(claim))} EUR", - "claim.paid": f"{money_text(allocated_total(data, claim_id))} EUR", + "claim.paid": f"{money_text(claim_settled_total(data, claim))} EUR", "claim.balance": f"{money_text(claim_balance(data, claim))} EUR", "claim.status": CLAIM_STATUS_LABELS.get(status, status), "claim.items": "; ".join(item_lines), diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index a10cc0f..d51b370 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), ) @@ -73,7 +79,9 @@ class Housekeeper: items = _items_by_key(working) successful_scopes: set[tuple[str, str]] = set() member_ids = set(self.repository.list_member_ids()) + asset_ids = set(self.repository.list_asset_ids()) _remove_orphaned_member_items(items, member_ids) + _remove_orphaned_asset_items(items, asset_ids) rules = load_rules(self.repository.root) repository_config = self.repository.get_configuration() @@ -87,6 +95,15 @@ class Housekeeper: successful_scopes.add(("member-record-check", member_id)) continue successful_scopes.add(("member-record-check", member_id)) + self._refresh_hash_integrity_tasks( + items, + target_type="member", + target_id=member_id, + warnings=self.repository.member_hash_warnings(member_id), + run_id=run_id, + now=now, + ) + successful_scopes.add(("member-hash-check", member_id)) for rule in rules: scope = (rule.rule_id, member.member_id) try: @@ -124,6 +141,17 @@ class Housekeeper: else: successful_scopes.add(scope) + for asset_id in sorted(asset_ids): + self._refresh_hash_integrity_tasks( + items, + target_type="asset", + target_id=asset_id, + warnings=self.repository.asset_hash_warnings(asset_id), + run_id=run_id, + now=now, + ) + successful_scopes.add(("asset-hash-check", asset_id)) + self._resolve_stale_tasks(items, successful_scopes, run_id, now) working.update( { @@ -192,6 +220,58 @@ class Housekeeper: ) items[key] = item + @staticmethod + def _refresh_hash_integrity_tasks( + items: dict[str, dict[str, Any]], + *, + target_type: str, + target_id: str, + warnings: list[str], + run_id: str, + now: str, + ) -> None: + key = f"{target_type}-hash-check:{target_id}:json-hash-mismatch" + if not warnings: + item = items.get(key) + if item and item.get("status") == "open": + item["status"] = "resolved" + item["resolved_run"] = run_id + item["resolved_at"] = now + return + item = items.get(key, {}) + was_resolved = item.get("status") == "resolved" + item.update( + { + "key": key, + "rule_id": f"{target_type}-hash-check", + "rule_file": "", + "rule_source": "housekeeper", + "member_id": target_id if target_type == "member" else "", + "asset_id": target_id if target_type == "asset" else "", + "target_type": target_type, + "action": "task", + "status": "open", + "severity": "warning", + "code": "json_hash_mismatch", + "title": ( + f"Mitgliederakte {target_id}: JSON extern geändert" + if target_type == "member" + else f"Assetakte {target_id}: JSON extern geändert" + ), + "detail": " ; ".join(warnings), + "due_date": None, + "first_seen_run": item.get("first_seen_run", run_id), + "first_seen_at": item.get("first_seen_at", now), + "last_seen_run": run_id, + "last_seen_at": now, + "seen_count": int(item.get("seen_count", 0)) + 1, + "reopened_count": int(item.get("reopened_count", 0)) + (1 if was_resolved else 0), + "resolved_run": None, + "resolved_at": None, + } + ) + items[key] = item + def _apply_action( self, items: dict[str, dict[str, Any]], @@ -337,7 +417,8 @@ class Housekeeper: for item in items.values(): if item.get("action") != "task" or item.get("status") != "open": continue - scope = (str(item.get("rule_id", "")), str(item.get("member_id", ""))) + target_id = str(item.get("member_id", "") or item.get("asset_id", "")) + scope = (str(item.get("rule_id", "")), target_id) if scope not in successful_scopes or item.get("last_seen_run") == run_id: continue item["status"] = "resolved" @@ -390,10 +471,12 @@ def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]: findings.append( HousekeeperFinding( severity=str(item.get("severity", "info")), - member_id=str(item.get("member_id", "")), code=str(item.get("code", item.get("rule_id", "housekeeper"))), title=str(item.get("title", item.get("key", "Hausmeister"))), detail=str(item.get("detail", "")), + member_id=str(item.get("member_id", "")), + asset_id=str(item.get("asset_id", "")), + target_type=str(item.get("target_type", "member")), due_date=due_date, key=str(item.get("key", "")), ) @@ -415,6 +498,16 @@ def _remove_orphaned_member_items(items: dict[str, dict[str, Any]], member_ids: del items[key] +def _remove_orphaned_asset_items(items: dict[str, dict[str, Any]], asset_ids: set[str]) -> None: + orphaned_keys = [ + key + for key, item in items.items() + if item.get("asset_id") and str(item["asset_id"]) not in asset_ids + ] + for key in orphaned_keys: + del items[key] + + def _non_negative_delay(value: float) -> float: try: delay = float(value) diff --git a/src/ccma/storage/atomic.py b/src/ccma/storage/atomic.py index 2cfd925..18536e5 100644 --- a/src/ccma/storage/atomic.py +++ b/src/ccma/storage/atomic.py @@ -1,17 +1,60 @@ +import hashlib import json import os import tempfile +from copy import deepcopy from pathlib import Path from typing import Any +CONTENT_HASH_FIELD = "content_hash" + + +def _hashable_copy(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any: + if isinstance(data, dict): + return { + key: _hashable_copy(value, hash_field=hash_field) + for key, value in data.items() + if key != hash_field + } + if isinstance(data, list): + return [_hashable_copy(item, hash_field=hash_field) for item in data] + return data + + +def compute_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> str: + payload = json.dumps( + _hashable_copy(data, hash_field=hash_field), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def attach_json_content_hash(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> Any: + cloned = deepcopy(data) + if isinstance(cloned, dict): + cloned[hash_field] = compute_json_content_hash(cloned, hash_field=hash_field) + return cloned + + +def json_content_hash_matches(data: Any, *, hash_field: str = CONTENT_HASH_FIELD) -> bool: + if not isinstance(data, dict): + return True + stored = str(data.get(hash_field, "")).strip() + if not stored: + return False + return stored == compute_json_content_hash(data, hash_field=hash_field) + def write_json_atomic(path: Path, data: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) temporary = Path(temporary_name) + payload = attach_json_content_hash(data) try: with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: - json.dump(data, handle, ensure_ascii=False, indent=2) + json.dump(payload, handle, ensure_ascii=False, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index dc5031d..5ae599a 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -13,14 +13,22 @@ from uuid import uuid4 from ccma.domain.contributions import ( claim_balance, claim_total, + credit_allocated_total, decimal_value, materialize_claim_items, money_text, payment_allocated_total, ) from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates -from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member -from ccma.storage.atomic import read_json, write_json_atomic +from ccma.domain.models import ( + ASSET_STATUS_LABELS, + MEMBERSHIP_STATUS_LABELS, + Asset, + ContributionData, + Event, + Member, +) +from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic class RepositoryError(RuntimeError): @@ -29,6 +37,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 +114,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, } @@ -97,9 +122,11 @@ class MemberRepository: def __init__(self, root: Path | str): self.root = Path(root).expanduser().resolve() self.members_root = self.root / "members" + self.assets_root = self.root / "assets" def initialize(self) -> None: self.members_root.mkdir(parents=True, exist_ok=True) + self.assets_root.mkdir(parents=True, exist_ok=True) (self.root / "rules").mkdir(parents=True, exist_ok=True) templates_root = self.root / "templates" templates_root.mkdir(parents=True, exist_ok=True) @@ -122,6 +149,10 @@ class MemberRepository: errors: list[str] = [] try: config = read_json(self.root / "repository.json") + if not json_content_hash_matches(config): + errors.append( + "repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) if int(config.get("schema_version", 0)) != 1: errors.append("repository.json: nicht unterstützte schema_version") policy = config.get("member_number_policy") or {} @@ -135,6 +166,10 @@ class MemberRepository: for member_dir in self._member_directories(): try: member, _contributions = self.preflight_member_record(member_dir.name) + errors.extend( + f"{member_dir.name}/{warning}" + for warning in self.member_hash_warnings(member_dir.name) + ) validate_member_dates( birth_date=member.birth_date, accepted_at=member.accepted_at, @@ -160,6 +195,35 @@ class MemberRepository: DateValidationError, ) as exc: errors.append(f"{member_dir.name}/member.json: {exc}") + for asset_dir in self._asset_directories(): + try: + asset = self.get_asset(asset_dir.name) + errors.extend( + f"{asset_dir.name}/{warning}" + for warning in self.asset_hash_warnings(asset_dir.name) + ) + if asset.asset_id != asset_dir.name: + errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein") + if asset.schema_version != 1: + errors.append( + f"{asset_dir.name}/asset.json: " + f"nicht unterstützte schema_version {asset.schema_version}" + ) + if asset.status not in ASSET_STATUS_LABELS: + errors.append(f"{asset_dir.name}/asset.json: ungültiger Asset-Status") + if asset.current_holder_member_id: + self.get_member(asset.current_holder_member_id) + if asset.status != "issued": + errors.append( + f"{asset_dir.name}/asset.json: zugeordnetes Asset muss Status issued haben" + ) + elif asset.status == "issued": + errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id") + self.get_asset_events(asset.asset_id) + except RepositoryError as exc: + errors.append(str(exc)) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + errors.append(f"{asset_dir.name}/asset.json: {exc}") return errors def list_members(self) -> list[Member]: @@ -209,6 +273,7 @@ class MemberRepository: *, first_name: str, last_name: str, + nickname: str = "", email: str = "", phone: str = "", birth_date: str = "", @@ -238,6 +303,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, @@ -294,6 +360,301 @@ class MemberRepository: actor_name=actor_name, ) + def list_assets(self) -> list[Asset]: + assets: list[Asset] = [] + for asset_id in self.list_asset_ids(): + try: + assets.append(self.get_asset(asset_id)) + except RepositoryError: + continue + return sorted(assets, key=lambda item: (item.label.casefold(), item.inventory_number.casefold())) + + def list_asset_ids(self) -> list[str]: + return sorted(directory.name for directory in self._asset_directories()) + + def get_asset(self, asset_id: str) -> Asset: + path = self._asset_path(asset_id) / "asset.json" + if not path.is_file(): + raise RepositoryError(f"Asset nicht gefunden: {asset_id}") + try: + raw = read_json(path) + if not isinstance(raw, dict): + raise TypeError("Wurzelelement muss ein JSON-Objekt sein") + return Asset.from_dict(raw) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raise RepositoryError(f"{asset_id}/asset.json konnte nicht gelesen werden: {exc}") from exc + + def create_asset( + self, + *, + label: str, + category: str = "", + inventory_number: str = "", + serial_number: str = "", + deposit_amount_default: str = "0", + notes: str = "", + ) -> Asset: + if not label.strip(): + raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.") + try: + deposit_amount = decimal_value(deposit_amount_default or "0", "Kaution") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if deposit_amount < 0: + raise RepositoryError("Die Kaution darf nicht negativ sein.") + asset_id = str(uuid4()) + directory = self._asset_path(asset_id) + directory.mkdir(parents=True, exist_ok=False) + (directory / "files").mkdir() + asset = Asset( + asset_id=asset_id, + label=label.strip(), + category=category.strip(), + inventory_number=inventory_number.strip(), + serial_number=serial_number.strip(), + deposit_amount_default=money_text(deposit_amount), + notes=notes.strip(), + ) + write_json_atomic(directory / "asset.json", asset.to_dict()) + self.append_asset_event( + asset.asset_id, + event_type="asset_created", + summary="Asset angelegt", + actor_type="user", + actor_name="Vorstand", + ) + return asset + + def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None: + existing = self.get_asset(asset.asset_id) + if not asset.label.strip(): + raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.") + if asset.status not in ASSET_STATUS_LABELS: + raise RepositoryError("Ungültiger Asset-Status.") + try: + deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if deposit_amount < 0: + raise RepositoryError("Die Kaution darf nicht negativ sein.") + if ( + existing.current_holder_member_id + and money_text(deposit_amount) != str(existing.deposit_amount_default) + ): + raise RepositoryError( + "Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist." + ) + asset.label = asset.label.strip() + asset.category = asset.category.strip() + asset.inventory_number = asset.inventory_number.strip() + asset.serial_number = asset.serial_number.strip() + asset.deposit_amount_default = money_text(deposit_amount) + asset.notes = asset.notes.strip() + if asset.current_holder_member_id: + self.get_member(asset.current_holder_member_id) + if asset.status != "issued": + raise RepositoryError("Ein zugeordnetes Asset muss den Status issued haben.") + elif asset.status == "issued": + raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.") + changes = self._summarize_asset_changes(existing, asset) + asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) + if changes: + self.append_asset_event( + asset.asset_id, + event_type="asset_data_changed", + summary=f"Assetdaten geändert: {', '.join(changes)}", + actor_type="user", + actor_name=actor_name, + ) + + def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset: + asset = self.get_asset(asset_id) + member = self.get_member(member_id) + if asset.current_holder_member_id: + raise RepositoryError("Das Asset ist bereits einem Mitglied zugeordnet.") + if asset.status in {"lost", "retired"}: + raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.") + asset.current_holder_member_id = member.member_id + asset.status = "issued" + asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) + self.append_asset_event( + asset.asset_id, + event_type="asset_issued", + summary=f"Asset ausgegeben an {member.member_number or member.member_id}", + actor_type="user", + actor_name=actor_name, + references={"member_id": member.member_id}, + ) + self.append_event( + member.member_id, + event_type="asset_assigned", + summary=f"Asset ausgegeben: {asset.label}", + actor_type="user", + actor_name=actor_name, + references={"asset_id": asset.asset_id}, + ) + return asset + + def return_asset(self, asset_id: str, *, actor_name: str = "Vorstand") -> Asset: + asset = self.get_asset(asset_id) + member_id = asset.current_holder_member_id + if not member_id: + raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.") + asset.current_holder_member_id = "" + asset.status = "available" + asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") + write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) + self.append_asset_event( + asset.asset_id, + event_type="asset_returned", + summary="Asset zurückgenommen", + actor_type="user", + actor_name=actor_name, + references={"member_id": member_id}, + ) + self.append_event( + member_id, + event_type="asset_returned", + summary=f"Asset zurückgegeben: {asset.label}", + actor_type="user", + actor_name=actor_name, + references={"asset_id": asset.asset_id}, + ) + return asset + + def list_member_assets(self, member_id: str) -> list[Asset]: + self.get_member(member_id) + return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id] + + def create_manual_claim( + self, + member_id: str, + *, + title: str, + amount: str, + due_date: str, + description: str = "", + claim_type: str = "asset_charge", + references: dict[str, str] | None = None, + actor_name: str = "Vorstand", + ) -> dict: + self.get_member(member_id) + if not title.strip(): + raise RepositoryError("Ein Forderungstitel ist erforderlich.") + try: + normalized_due_date = normalize_date_input(due_date, "Fälligkeitsdatum") + amount_value = decimal_value(amount) + except (DateValidationError, ValueError) as exc: + raise RepositoryError(str(exc)) from exc + if not normalized_due_date: + raise RepositoryError("Ein Fälligkeitsdatum ist erforderlich.") + if amount_value == 0: + raise RepositoryError("Der Betrag darf nicht null sein.") + now = datetime.now().astimezone().isoformat(timespec="seconds") + claim_id = str(uuid4()) + item_type = "credit" if amount_value < 0 else "base" + claim = { + "claim_id": claim_id, + "claim_key": f"manual:{claim_type}:{claim_id}", + "title": title.strip(), + "amount": money_text(amount_value), + "due_date": normalized_due_date, + "status": "open", + "created_at": now, + "items": [ + { + "item_id": str(uuid4()), + "type": item_type, + "description": description.strip() or title.strip(), + "quantity": "1.00", + "unit_price": money_text(amount_value), + "amount": money_text(amount_value), + "created_at": now, + } + ], + "origin": { + "type": "manual", + "subtype": claim_type.strip() or "asset_charge", + "actor": actor_name, + "asset_id": (references or {}).get("asset_id", ""), + }, + } + data = self.get_contributions(member_id) + data.claims.append(claim) + self.save_contributions(member_id, data) + event = self.append_event( + member_id, + event_type="claim_created", + summary=f"Forderung angelegt: {claim['title']}", + actor_type="user", + actor_name=actor_name, + references={"claim_id": claim_id, **(references or {})}, + data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type}, + ) + if references and references.get("asset_id"): + self.append_asset_event( + references["asset_id"], + event_type="asset_claim_created", + summary=f"Forderung für Asset angelegt: {claim['title']}", + actor_type="user", + actor_name=actor_name, + references={"member_id": member_id, "claim_id": claim_id}, + data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type}, + ) + return {"claim": claim, "event": event} + + def append_asset_event( + self, + asset_id: str, + *, + event_type: str, + summary: str, + actor_type: str = "system", + actor_name: str = "CCMA", + references: dict[str, str] | None = None, + data: dict[str, object] | None = None, + ) -> Event: + directory = self._asset_path(asset_id) + if not (directory / "asset.json").is_file(): + raise RepositoryError(f"Asset nicht gefunden: {asset_id}") + event = Event( + event_id=str(uuid4()), + timestamp=datetime.now().astimezone().isoformat(timespec="seconds"), + event_type=event_type, + summary=summary.strip(), + actor_type=actor_type, + actor_name=actor_name, + references=references or {}, + data=data or {}, + ) + path = directory / "events.jsonl" + line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n" + with path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(line) + handle.flush() + os.fsync(handle.fileno()) + return event + + def get_asset_events(self, asset_id: str) -> list[Event]: + path = self._asset_path(asset_id) / "events.jsonl" + if not path.exists(): + return [] + events: list[Event] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + raw = json.loads(line) + if not isinstance(raw, dict): + raise TypeError("Event muss ein JSON-Objekt sein") + events.append(Event.from_dict(raw)) + except (AttributeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc + return events + def get_contributions(self, member_id: str) -> ContributionData: path = self._member_path(member_id) / "contributions.json" if not path.exists(): @@ -302,7 +663,7 @@ class MemberRepository: raw = read_json(path) if not isinstance(raw, dict): raise TypeError("Wurzelelement muss ein JSON-Objekt sein") - for field_name in ("claims", "payments", "allocations", "reminders"): + for field_name in ("claims", "payments", "credits", "allocations", "reminders"): if field_name in raw and not isinstance(raw[field_name], list): raise TypeError(f"{field_name} muss eine JSON-Liste sein") if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]): @@ -457,6 +818,92 @@ class MemberRepository: ) return allocation + def record_credit( + self, + member_id: str, + claim_id: str, + *, + credit_date: str, + amount: str, + allocation_amount: str, + reference: str = "", + ) -> dict: + try: + normalized_date = normalize_date_input(credit_date, "Gutschriftsdatum") + selected_amount = decimal_value(amount, "Gutschrift") + selected_allocation = decimal_value(allocation_amount, "Zuordnung") + except (DateValidationError, ValueError) as exc: + raise RepositoryError(str(exc)) from exc + if not normalized_date: + raise RepositoryError("Gutschriftsdatum ist erforderlich.") + if selected_amount <= 0: + raise RepositoryError("Der Gutschriftsbetrag muss größer als null sein.") + if selected_allocation <= 0 or selected_allocation > selected_amount: + raise RepositoryError( + "Die Zuordnung muss größer als null und höchstens so hoch wie die Gutschrift sein." + ) + data, claim = self.get_claim(member_id, claim_id) + if claim_total(claim) >= 0: + raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.") + credit = { + "credit_id": str(uuid4()), + "date": normalized_date, + "amount": money_text(selected_amount), + "method": "payout", + "reference": reference.strip(), + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + allocation = { + "allocation_id": str(uuid4()), + "credit_id": credit["credit_id"], + "claim_id": claim_id, + "amount": money_text(selected_allocation), + } + data.credits.append(credit) + data.allocations.append(allocation) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="credit_recorded", + summary=f"Gutschrift / Auszahlung erfasst: {credit['amount']} EUR", + references={"claim_id": claim_id, "credit_id": str(credit["credit_id"])}, + data={"allocation_amount": allocation["amount"]}, + ) + return credit + + def allocate_credit(self, member_id: str, claim_id: str, *, credit_id: str, amount: str) -> dict: + data, claim = self.get_claim(member_id, claim_id) + if claim_total(claim) >= 0: + raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.") + credit = next( + (item for item in data.credits if str(item.get("credit_id", "")) == credit_id), + None, + ) + if credit is None: + raise RepositoryError("Gutschrift nicht gefunden.") + try: + selected_amount = decimal_value(amount, "Zuordnung") + available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id) + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if selected_amount <= 0 or selected_amount > available: + raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Gutschrift verfügbar.") + allocation = { + "allocation_id": str(uuid4()), + "credit_id": credit_id, + "claim_id": claim_id, + "amount": money_text(selected_amount), + } + data.allocations.append(allocation) + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="credit_allocated", + summary=f"Gutschrift zugeordnet: {allocation['amount']} EUR", + references={"claim_id": claim_id, "credit_id": credit_id}, + ) + return allocation + def create_reminder_draft( self, member_id: str, @@ -752,6 +1199,7 @@ class MemberRepository: member.member_number, member.first_name, member.last_name, + member.nickname, member.display_name, member.email, member.phone, @@ -773,6 +1221,9 @@ class MemberRepository: def member_count(self) -> int: return sum(1 for _ in self._member_directories()) + def asset_count(self) -> int: + return sum(1 for _ in self._asset_directories()) + def get_configuration(self) -> dict: try: configuration = read_json(self.root / "repository.json") @@ -782,6 +1233,49 @@ class MemberRepository: raise RepositoryError("repository.json enthält keine gültige Konfiguration.") return configuration + def member_hash_warnings(self, member_id: str) -> list[str]: + warnings: list[str] = [] + try: + member_raw = read_json(self._member_path(member_id) / "member.json") + if not json_content_hash_matches(member_raw): + warnings.append( + "member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + pass + try: + contributions_raw = read_json(self._member_path(member_id) / "contributions.json") + if not json_content_hash_matches(contributions_raw): + warnings.append( + "contributions.json: Hash fehlt oder stimmt nicht; " + "Datei wurde vermutlich extern geändert." + ) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + pass + return warnings + + def asset_hash_warnings(self, asset_id: str) -> list[str]: + warnings: list[str] = [] + try: + asset_raw = read_json(self._asset_path(asset_id) / "asset.json") + if not json_content_hash_matches(asset_raw): + warnings.append( + "asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert." + ) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + pass + return warnings + + def refresh_member_record_hashes(self, member_id: str) -> None: + member = self.get_member(member_id) + contributions = self.get_contributions(member_id) + write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict()) + write_json_atomic(self._member_path(member_id) / "contributions.json", contributions.to_dict()) + + def refresh_asset_record_hashes(self, asset_id: str) -> None: + asset = self.get_asset(asset_id) + write_json_atomic(self._asset_path(asset_id) / "asset.json", asset.to_dict()) + def get_member_number_policy(self) -> dict[str, str]: try: config = read_json(self.root / "repository.json") @@ -829,11 +1323,23 @@ class MemberRepository: path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".") ) + def _asset_directories(self) -> Iterable[Path]: + if not self.assets_root.exists(): + return [] + return ( + path for path in self.assets_root.iterdir() if path.is_dir() and not path.name.startswith(".") + ) + def _member_path(self, member_id: str) -> Path: if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}: raise RepositoryError("Ungültige Mitglieds-ID.") return self.members_root / member_id + def _asset_path(self, asset_id: str) -> Path: + if not asset_id or Path(asset_id).name != asset_id or asset_id in {".", ".."}: + raise RepositoryError("Ungültige Asset-ID.") + return self.assets_root / asset_id + def _allocate_member_number(self, pattern: str) -> str: config = read_json(self.root / "repository.json") member_number, next_value = self._next_available_member_number(config, pattern) @@ -880,6 +1386,7 @@ class MemberRepository: "member_number": "Mitgliedsnummer", "first_name": "Vorname", "last_name": "Nachname", + "nickname": "Nickname", "email": "E-Mail-Adresse", "phone": "Telefonnummer", "birth_date": "Geburtsdatum", @@ -915,6 +1422,24 @@ class MemberRepository: changes.append(label) return changes + @staticmethod + def _summarize_asset_changes(before: Asset, after: Asset) -> list[str]: + labels = { + "label": "Bezeichnung", + "category": "Kategorie", + "inventory_number": "Inventarnummer", + "serial_number": "Seriennummer", + "status": "Status", + "current_holder_member_id": "Zuordnung", + "deposit_amount_default": "Kaution", + "notes": "Notiz", + } + changes: list[str] = [] + for field, label in labels.items(): + if getattr(before, field) != getattr(after, field): + changes.append(label) + return changes + def normalize_iban(value: str) -> str: return "".join(value.split()).upper() diff --git a/src/ccma/ui/asset_tab.py b/src/ccma/ui/asset_tab.py new file mode 100644 index 0000000..8b98abd --- /dev/null +++ b/src/ccma/ui/asset_tab.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from datetime import datetime +from tkinter import messagebox, ttk + +from ccma.domain.models import ASSET_STATUS_LABELS, Event +from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog +from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage +from ccma.ui.scrolling import ScrollableFrame + + +class AssetTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + asset_id: str, + on_close: Callable[[], None], + on_changed: Callable[[], None], + on_open_member: Callable[[str], None], + on_issue_asset: Callable[[str], None], + on_return_asset: Callable[[str], None], + on_open_claim: Callable[[str, str], None], + ): + super().__init__(master, padding=12) + self.repository = repository + self.asset_id = asset_id + self.on_close = on_close + self.on_changed = on_changed + self.on_open_member = on_open_member + self.on_issue_asset = on_issue_asset + self.on_return_asset = on_return_asset + self.on_open_claim = on_open_claim + self.asset = repository.get_asset(asset_id) + self.variables: dict[str, tk.StringVar] = {} + self.notes_text: tk.Text | None = None + self._build_ui() + self.refresh() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(2, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + header.columnconfigure(1, weight=0) + self.title_var = tk.StringVar() + self.subtitle_var = tk.StringVar() + self.id_var = tk.StringVar() + title_column = ttk.Frame(header) + title_column.grid(row=0, column=0, sticky="ew") + title_column.columnconfigure(0, weight=1) + ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid( + row=0, column=0, sticky="w" + ) + ttk.Label(title_column, textvariable=self.subtitle_var, style="Mono.TLabel").grid( + row=1, column=0, sticky="w" + ) + ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid( + row=2, column=0, sticky="w", pady=(3, 0) + ) + ttk.Button(header, text="Tab schließen", command=self.on_close).grid( + row=0, column=1, sticky="ne", padx=(12, 0) + ) + self.messages = MessageBannerList(self) + self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + self.messages.grid_remove() + + self.pane = ttk.Panedwindow(self, orient="horizontal") + self.pane.grid(row=2, column=0, sticky="nsew") + self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) + self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) + self.pane.add(self.details_pane, weight=2) + self.pane.add(self.timeline_pane, weight=3) + self._build_details(self.details_pane) + self._build_timeline(self.timeline_pane) + self._pane_position_initialized = False + self.pane.bind("", self._set_initial_pane_position, add="+") + + def _set_initial_pane_position(self, event: tk.Event | None = None) -> None: + if self._pane_position_initialized: + return + try: + width = int(getattr(event, "width", 0)) or self.pane.winfo_width() + if width > 1: + self.pane.sashpos(0, max(360, int(width * 0.4))) + self._pane_position_initialized = True + except tk.TclError: + return + + def _build_details(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + ttk.Label(parent, text="// ASSET", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) + notebook = ttk.Notebook(parent) + notebook.grid(row=1, column=0, sticky="nsew") + data_tab = self._create_form_tab(notebook, "Stammdaten") + actions = ttk.Frame(parent) + actions.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + actions.columnconfigure(0, weight=1) + action_buttons = ttk.Frame(actions) + action_buttons.grid(row=0, column=0, sticky="e") + finance_tab = ttk.Frame(notebook, padding=16) + notebook.add(finance_tab, text="Forderungen") + + fields = [ + ("UUID / Ordner-ID", "asset_id"), + ("Bezeichnung", "label"), + ("Kategorie", "category"), + ("Inventarnummer", "inventory_number"), + ("Seriennummer", "serial_number"), + ("Kaution (EUR)", "deposit_amount_default"), + ] + for row, (label, key) in enumerate(fields): + self.variables[key] = tk.StringVar() + ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42) + entry.grid(row=row, column=1, sticky="ew", pady=5) + if key == "asset_id": + entry.configure(state="readonly") + if key == "deposit_amount_default": + self.deposit_entry = entry + self.variables["status"] = tk.StringVar() + holder_row = len(fields) + ttk.Label(data_tab, text="Status").grid(row=holder_row, column=0, sticky="w", pady=5, padx=(0, 12)) + self.status_box = ttk.Combobox( + data_tab, + textvariable=self.variables["status"], + values=["VERFUEGBAR", "AUSGEGEBEN", "VERLOREN", "AUSGEMUSTERT"], + state="readonly", + width=39, + ) + self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5) + self.holder_var = tk.StringVar() + ttk.Label(data_tab, text="Aktueller Halter").grid( + row=holder_row + 1, + column=0, + sticky="w", + pady=5, + padx=(0, 12), + ) + self.holder_label = ttk.Label( + data_tab, + textvariable=self.holder_var, + style="TimelineHeader.TLabel", + ) + self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5) + self.holder_label.bind("", lambda _event: self._open_holder_member(), add="+") + ttk.Label(data_tab, text="Interne Notiz").grid( + row=holder_row + 2, + column=0, + sticky="nw", + pady=5, + padx=(0, 12), + ) + self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word") + self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5) + data_tab.columnconfigure(1, weight=1) + self.issue_button = ttk.Button( + action_buttons, + text="Ausgeben", + command=lambda: self.on_issue_asset(self.asset_id), + ) + self.issue_button.pack(side="left", padx=(0, 8)) + self.return_button = ttk.Button( + action_buttons, + text="Zurücknehmen", + command=lambda: self.on_return_asset(self.asset_id), + ) + self.return_button.pack(side="left", padx=(0, 8)) + ttk.Button( + action_buttons, + text="Speichern", + style="Accent.TButton", + command=self._save, + ).pack(side="left") + + finance_tab.columnconfigure(0, weight=1) + self.finance_summary_var = tk.StringVar() + ttk.Label(finance_tab, textvariable=self.finance_summary_var, style="Mono.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + finance_actions = ttk.Frame(finance_tab) + finance_actions.grid(row=1, column=0, sticky="ew") + ttk.Button(finance_actions, text="Kautionsforderung", command=self._create_deposit_claim).pack( + side="left", padx=(0, 8) + ) + ttk.Button(finance_actions, text="Kautionsrückzahlung", command=self._create_refund_claim).pack( + side="left", padx=(0, 8) + ) + ttk.Button(finance_actions, text="Verlustforderung", command=self._create_loss_claim).pack( + side="left", padx=(0, 8) + ) + ttk.Button( + finance_actions, + text="Reparaturforderung", + command=self._create_repair_claim, + ).pack(side="left") + self.asset_claims = ttk.Treeview( + finance_tab, + columns=("title", "due", "amount", "member"), + show="headings", + ) + for key, title, width in ( + ("title", "Forderung", 240), + ("due", "Fällig", 110), + ("amount", "Betrag", 90), + ("member", "Mitglied", 220), + ): + self.asset_claims.heading(key, text=title) + self.asset_claims.column(key, width=width, anchor="w") + self.asset_claims.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) + finance_tab.rowconfigure(2, weight=1) + self.asset_claims.bind("", lambda _event: self._open_selected_asset_claim()) + self.asset_claims.bind("", lambda _event: self._open_selected_asset_claim()) + + def _create_form_tab(self, notebook: ttk.Notebook, title: str) -> ttk.Frame: + tab = ttk.Frame(notebook) + tab.columnconfigure(0, weight=1) + tab.rowconfigure(0, weight=1) + scroller = ScrollableFrame(tab, padding=16) + scroller.grid(row=0, column=0, sticky="nsew") + notebook.add(tab, text=title) + return scroller.content + + def _build_timeline(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) + self.timeline = ttk.Treeview( + parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview" + ) + self.timeline.heading("time", text="Zeit") + self.timeline.heading("summary", text="Ereignis") + self.timeline.column("time", width=135, stretch=False) + self.timeline.column("summary", width=320, stretch=True) + self.timeline.grid(row=1, column=0, sticky="nsew") + compose = ttk.Frame(parent) + compose.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + compose.columnconfigure(0, weight=1) + self.comment_var = tk.StringVar() + comment = ttk.Entry(compose, textvariable=self.comment_var) + comment.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + comment.bind("", lambda _event: self._add_comment()) + ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) + + def refresh(self) -> None: + self.asset = self.repository.get_asset(self.asset_id) + self.title_var.set(self.asset.label or self.asset.asset_id) + holder = self._holder_label() + status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()) + self.subtitle_var.set(f"{self.asset.inventory_number or '—'} · {status} · {holder}") + self.id_var.set(f"UUID: {self.asset.asset_id}") + warnings = self.repository.asset_hash_warnings(self.asset_id) + self.messages.set_messages( + [ + TabMessage( + "warning", + "WARNUNG: " + " | ".join(warnings), + MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner), + ) + ] + if warnings + else [] + ) + self.variables["asset_id"].set(self.asset.asset_id) + self.variables["label"].set(self.asset.label) + self.variables["category"].set(self.asset.category) + self.variables["inventory_number"].set(self.asset.inventory_number) + self.variables["serial_number"].set(self.asset.serial_number) + self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default) + self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())) + if self.notes_text is not None: + self.notes_text.delete("1.0", "end") + self.notes_text.insert("1.0", self.asset.notes) + self.holder_var.set(holder) + issued = bool(self.asset.current_holder_member_id) + self.holder_label.configure(cursor="hand2" if issued else "") + self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled") + self.return_button.configure(state="normal" if issued else "disabled") + self.status_box.configure(state="disabled" if issued else "readonly") + deposit_state = "readonly" if issued else "normal" + self.deposit_entry.configure(state=deposit_state) + self._refresh_events() + self._refresh_finance() + + def _holder_label(self) -> str: + if not self.asset.current_holder_member_id: + return "—" + try: + member = self.repository.get_member(self.asset.current_holder_member_id) + except RepositoryError: + return self.asset.current_holder_member_id + return f"{member.member_number or member.member_id} · {member.display_name}" + + def _refresh_events(self) -> None: + self.timeline.delete(*self.timeline.get_children()) + try: + events = self.repository.get_asset_events(self.asset_id) + except RepositoryError as exc: + messagebox.showerror("Chronik beschädigt", str(exc), parent=self) + return + for event in reversed(events): + self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) + + def _refresh_finance(self) -> None: + self.asset_claims.delete(*self.asset_claims.get_children()) + linked_claims: list[tuple[str, str, dict]] = [] + for member in self.repository.list_members(): + try: + data = self.repository.get_contributions(member.member_id) + except RepositoryError: + continue + for claim in data.claims: + origin = claim.get("origin") or {} + if isinstance(origin, dict) and str(origin.get("asset_id", "")) == self.asset_id: + linked_claims.append((member.member_id, member.display_name, claim)) + for member_id, member_name, claim in linked_claims: + claim_id = str(claim.get("claim_id", "")) + self.asset_claims.insert( + "", + "end", + iid=f"{member_id}:{claim_id}", + values=( + str(claim.get("title", "")), + str(claim.get("due_date", "")), + str(claim.get("amount", "")), + member_name, + ), + ) + self.finance_summary_var.set( + "Forderungen für dieses Asset. Negative Betraege dokumentieren Gutschriften/Rueckzahlungen." + ) + + def _save(self) -> None: + warnings = self.repository.asset_hash_warnings(self.asset_id) + if warnings: + self._confirm_integrity_and_then(self._save_confirmed) + return + self._save_confirmed() + + def _save_confirmed(self) -> None: + self.asset.label = self.variables["label"].get().strip() + self.asset.category = self.variables["category"].get().strip() + self.asset.inventory_number = self.variables["inventory_number"].get().strip() + self.asset.serial_number = self.variables["serial_number"].get().strip() + self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else "" + if not self.asset.current_holder_member_id: + self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip() + self.asset.status = _asset_status_key(self.variables["status"].get()) + try: + self.repository.save_asset(self.asset) + self.repository.refresh_asset_record_hashes(self.asset_id) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self) + return + self.refresh() + self.on_changed() + + def _confirm_integrity_banner(self) -> None: + self._confirm_integrity_and_then(self._refresh_hashes_only) + + def _refresh_hashes_only(self) -> None: + self.repository.refresh_asset_record_hashes(self.asset_id) + self.refresh() + self.on_changed() + + def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None: + warnings = self.repository.asset_hash_warnings(self.asset_id) + if not warnings: + callback() + return + IntegrityWarningDialog( + self, + title="Externe Änderungen bestätigen", + warnings=warnings, + on_confirm=callback, + ) + + def _add_comment(self) -> None: + text = self.comment_var.get().strip() + if not text: + return + self.repository.append_asset_event( + self.asset_id, + event_type="board_comment", + summary=text, + actor_type="user", + actor_name="Vorstand", + ) + self.comment_var.set("") + self._refresh_events() + + def _open_holder_member(self) -> None: + if self.asset.current_holder_member_id: + self.on_open_member(self.asset.current_holder_member_id) + + def _create_deposit_claim(self) -> None: + self._open_claim_dialog( + preset_title=f"Kaution: {self.asset.label}", + preset_amount=self.asset.deposit_amount_default or "0.00", + preset_description=f"Kaution für Asset {self.asset.label}", + claim_type="asset_deposit", + ) + + def _create_refund_claim(self) -> None: + amount = self.asset.deposit_amount_default or "0.00" + if not str(amount).startswith("-"): + amount = f"-{amount}" + self._open_claim_dialog( + preset_title=f"Kautionsrückzahlung: {self.asset.label}", + preset_amount=amount, + preset_description=f"Gutschrift / Rückzahlung der Kaution für Asset {self.asset.label}", + claim_type="asset_refund", + ) + + def _create_loss_claim(self) -> None: + self._open_claim_dialog( + preset_title=f"Verlust: {self.asset.label}", + preset_amount=self.asset.deposit_amount_default or "0.00", + preset_description=f"Forderung wegen Verlust von Asset {self.asset.label}", + claim_type="asset_loss", + ) + + def _create_repair_claim(self) -> None: + self._open_claim_dialog( + preset_title=f"Reparatur: {self.asset.label}", + preset_amount="0.00", + preset_description=f"Reparaturkosten für Asset {self.asset.label}", + claim_type="asset_repair", + ) + + def _open_claim_dialog( + self, + *, + preset_title: str, + preset_amount: str, + preset_description: str, + claim_type: str, + ) -> None: + member_id = self.asset.current_holder_member_id + if not member_id: + messagebox.showerror( + "Forderung nicht möglich", + "Dieses Asset ist aktuell keinem Mitglied zugeordnet.", + parent=self, + ) + return + AssetClaimDialog( + self, + self.repository, + self.asset_id, + member_id, + preset_title=preset_title, + preset_amount=preset_amount, + preset_description=preset_description, + claim_type=claim_type, + on_created=lambda claim_id: self._claim_created(member_id, claim_id), + ) + + def _claim_created(self, member_id: str, claim_id: str) -> None: + self.refresh() + self.on_changed() + self.on_open_claim(member_id, claim_id) + + def _open_selected_asset_claim(self) -> None: + selected = self.asset_claims.selection() + if not selected: + return + member_id, claim_id = selected[0].split(":", 1) + self.on_open_claim(member_id, claim_id) + + +def _format_timestamp(event: Event) -> str: + try: + return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M") + except ValueError: + return event.timestamp[:16] + + +def _event_label(event: Event) -> str: + if event.actor_type == "system": + return f"[AUTO] {event.summary}" + return event.summary + + +def _asset_status_key(label: str) -> str: + for key, value in ASSET_STATUS_LABELS.items(): + if value == label: + return key + return label.casefold().strip() diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 203daf9..1f84bb1 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -12,8 +12,10 @@ from ccma.domain.contributions import ( allocated_total, claim_balance, claim_items, + claim_settled_total, claim_status, claim_total, + credit_allocated_total, decimal_value, money_text, payment_allocated_total, @@ -125,6 +127,8 @@ class ClaimTab(ttk.Frame): self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff") self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff") self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff") + self.ledger.tag_configure("credit-group", background="#8b3d88", foreground="#ffffff") + self.ledger.tag_configure("credit", background="#5e2f5b", foreground="#ffffff") self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff") self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff") self.ledger.bind("<>", lambda _event: self._update_reminder_buttons()) @@ -137,6 +141,10 @@ class ClaimTab(ttk.Frame): side="left", padx=(0, 8) ) ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left") + ttk.Button(buttons, text="Vorhandene Gutschrift zuordnen", command=self._allocate_credit).pack( + side="left", padx=(8, 8) + ) + ttk.Button(buttons, text="Gutschrift erfassen", command=self._record_credit).pack(side="left") ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10) self.discard_reminder_button = ttk.Button( buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled" @@ -159,7 +167,7 @@ class ClaimTab(ttk.Frame): messagebox.showerror("Forderung konnte nicht geladen werden", str(exc), parent=self) return total = claim_total(self.claim) - paid = allocated_total(self.data, self.claim_id) + paid = claim_settled_total(self.data, self.claim) balance = claim_balance(self.data, self.claim) status = claim_status(self.data, self.claim) self.title_var.set(str(self.claim.get("title") or "Forderung")) @@ -227,6 +235,8 @@ class ClaimTab(ttk.Frame): ) for allocation in allocations: payment = payment_by_id.get(str(allocation.get("payment_id", "")), {}) + if not payment: + continue payment_total = str(payment.get("amount", "")) gnucash_id = str(payment.get("gnucash_transaction_id", "")) self.ledger.insert( @@ -246,6 +256,57 @@ class ClaimTab(ttk.Frame): tags=("payment",), ) + credits_by_id = {str(item.get("credit_id")): item for item in self.data.credits} + credit_allocations = [ + allocation + for allocation in self.data.allocations + if str(allocation.get("claim_id", "")) == self.claim_id and str(allocation.get("credit_id", "")) + ] + allocated_credit_total = money_text( + sum( + (decimal_value(item.get("amount", "0")) for item in credit_allocations), + Decimal("0"), + ) + ) + credit_group = self.ledger.insert( + "", + "end", + iid="group:credits", + text=f"Gutschriften ({len(credit_allocations)})", + values=( + "", + "", + "", + "", + f"{allocated_credit_total} EUR", + "", + "", + ), + tags=("credit-group",), + open=True, + ) + for allocation in credit_allocations: + credit = credits_by_id.get(str(allocation.get("credit_id", "")), {}) + if not credit: + continue + credit_total = str(credit.get("amount", "")) + self.ledger.insert( + credit_group, + "end", + iid=f"credit-allocation:{allocation.get('allocation_id', '')}", + text="Gutschrift", + values=( + format_date_for_display(str(credit.get("date", ""))), + credit.get("reference", ""), + "", + "", + f"{allocation.get('amount', '')} EUR", + f"Gutschrift gesamt: {credit_total} EUR", + "", + ), + tags=("credit",), + ) + reminders = [ reminder for reminder in self.data.reminders @@ -309,6 +370,26 @@ class ClaimTab(ttk.Frame): self._changed, ) + def _record_credit(self) -> None: + CreditDialog( + self, + self.repository, + self.member_id, + self.claim_id, + claim_balance(self.data, self.claim), + self._changed, + ) + + def _allocate_credit(self) -> None: + AllocateCreditDialog( + self, + self.repository, + self.member_id, + self.claim_id, + claim_balance(self.data, self.claim), + self._changed, + ) + def _add_reminder(self) -> None: ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) @@ -508,6 +589,45 @@ class PaymentDialog(_Dialog): self.on_saved() +class CreditDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): + super().__init__(master, "Gutschrift erfassen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + initial = money_text(max(-balance, Decimal("0"))) + self.variables = { + "date": tk.StringVar(value=format_date_for_display(date.today().isoformat())), + "amount": tk.StringVar(value=initial), + "allocation": tk.StringVar(value=initial), + "reference": tk.StringVar(), + } + fields = ( + (f"Gutschriftsdatum ({date_input_hint()})", "date"), + ("Gutschriftsbetrag", "amount"), + ("Dieser Gutschrift zuordnen", "allocation"), + ("Referenz", "reference"), + ) + for row, (label, key) in enumerate(fields): + ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5) + self._buttons(len(fields), self._save) + + def _save(self): + try: + self.repository.record_credit( + self.member_id, + self.claim_id, + credit_date=self.variables["date"].get(), + amount=self.variables["amount"].get(), + allocation_amount=self.variables["allocation"].get(), + reference=self.variables["reference"].get(), + ) + except RepositoryError as exc: + messagebox.showerror("Gutschrift konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + class AllocatePaymentDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved) @@ -560,6 +680,58 @@ class AllocatePaymentDialog(_Dialog): self.on_saved() +class AllocateCreditDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, balance: Decimal, on_saved): + super().__init__(master, "Vorhandene Gutschrift zuordnen", on_saved) + self.repository, self.member_id, self.claim_id = repository, member_id, claim_id + data = repository.get_contributions(member_id) + self.credit_by_label = {} + for credit in data.credits: + credit_id = str(credit.get("credit_id", "")) + available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id) + if available <= 0: + continue + label = ( + f"{credit.get('date', '')} · {money_text(available)} EUR frei · " + f"{credit.get('reference', '')}" + ) + self.credit_by_label[label] = (credit_id, available) + self.credit_var = tk.StringVar() + self.amount_var = tk.StringVar(value=money_text(max(-balance, Decimal("0")))) + ttk.Label(self.frame, text="Gutschrift").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + combo = ttk.Combobox( + self.frame, + textvariable=self.credit_var, + values=list(self.credit_by_label), + state="readonly", + width=60, + ) + combo.grid(row=0, column=1, pady=5) + ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5) + combo.bind("<>", lambda _event: self._select(balance)) + self._buttons(2, self._save) + + def _select(self, balance): + _credit_id, available = self.credit_by_label[self.credit_var.get()] + self.amount_var.set(money_text(min(available, max(-balance, Decimal("0"))))) + + def _save(self): + selected = self.credit_by_label.get(self.credit_var.get()) + if not selected: + messagebox.showerror("Gutschrift auswählen", "Bitte eine Gutschrift auswählen.", parent=self) + return + try: + self.repository.allocate_credit( + self.member_id, self.claim_id, credit_id=selected[0], amount=self.amount_var.get() + ) + except RepositoryError as exc: + messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + class ReminderDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Mahnung vorbereiten", on_saved) diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py index 7c52d3f..246ef7e 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -1,12 +1,28 @@ import tkinter as tk from collections.abc import Callable +from datetime import date from tkinter import messagebox, ttk from ccma.domain.dates import age_label, date_input_hint -from ccma.domain.models import Member +from ccma.domain.models import Asset, Member from ccma.storage.repository import MemberRepository, RepositoryError +def _activate_modal_window(window: tk.Toplevel, focus_widget: tk.Widget | None = None) -> None: + try: + window.update_idletasks() + window.deiconify() + window.lift() + window.focus_force() + if focus_widget is not None and focus_widget.winfo_exists(): + focus_widget.focus_set() + window.grab_set() + window.attributes("-topmost", True) + window.after_idle(lambda: window.winfo_exists() and window.attributes("-topmost", False)) + except tk.TclError: + return + + class NewMemberDialog(tk.Toplevel): def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Member], None]): super().__init__(master) @@ -14,24 +30,36 @@ class NewMemberDialog(tk.Toplevel): self.on_created = on_created self.title("Neue Mitgliederakte") self.transient(master.winfo_toplevel()) - self.grab_set() self.resizable(False, False) 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()) self.bind("", lambda _event: self._create()) + self.after_idle(self._activate_modal) self.after_idle(self._focus_first) + def _activate_modal(self) -> None: + _activate_modal_window(self, self.entries.get("first_name")) + def _build_ui(self) -> None: frame = ttk.Frame(self, padding=18) frame.pack(fill="both", expand=True) fields = [ ("Vorname *", "first_name"), ("Nachname *", "last_name"), + ("Nickname", "nickname"), ("E-Mail-Adresse", "email"), ("Telefonnummer", "phone"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"), @@ -89,3 +117,504 @@ class NewMemberDialog(tk.Toplevel): return self.destroy() self.on_created(member) + + +class NewAssetDialog(tk.Toplevel): + def __init__(self, master: tk.Misc, repository: MemberRepository, on_created: Callable[[Asset], None]): + super().__init__(master) + self.repository = repository + self.on_created = on_created + self.title("Neues Asset") + self.transient(master.winfo_toplevel()) + self.resizable(False, False) + self.variables = { + name: tk.StringVar() + for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default") + } + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._create()) + self.after_idle(self._activate_modal) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + fields = [ + ("Bezeichnung *", "label"), + ("Kategorie", "category"), + ("Inventarnummer", "inventory_number"), + ("Seriennummer", "serial_number"), + ("Kaution (EUR)", "deposit_amount_default"), + ] + self.entries: dict[str, ttk.Entry] = {} + for row, (label, key) in enumerate(fields): + ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) + entry.grid(row=row, column=1, sticky="ew", pady=5) + self.entries[key] = entry + ttk.Label(frame, text="Interne Notiz").grid( + row=len(fields), + column=0, + sticky="nw", + pady=5, + padx=(0, 12), + ) + self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") + self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5) + buttons = ttk.Frame(frame) + buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button( + buttons, + text="Asset anlegen", + style="Accent.TButton", + command=self._create, + ).pack(side="left") + self.after_idle(lambda: self.entries["label"].focus_set()) + + def _activate_modal(self) -> None: + _activate_modal_window(self, self.entries.get("label")) + + def _create(self) -> None: + try: + asset = self.repository.create_asset( + **{key: variable.get() for key, variable in self.variables.items()}, + notes=self.notes_text.get("1.0", "end-1c"), + ) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht angelegt werden", str(exc), parent=self) + return + self.destroy() + self.on_created(asset) + + +class EditAssetDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + asset_id: str, + on_saved: Callable[[Asset], None], + ): + super().__init__(master) + self.repository = repository + self.asset_id = asset_id + self.on_saved = on_saved + self.asset = repository.get_asset(asset_id) + self.title("Asset bearbeiten") + self.transient(master.winfo_toplevel()) + self.resizable(False, False) + self.variables = { + "label": tk.StringVar(value=self.asset.label), + "category": tk.StringVar(value=self.asset.category), + "inventory_number": tk.StringVar(value=self.asset.inventory_number), + "serial_number": tk.StringVar(value=self.asset.serial_number), + "deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default), + "status": tk.StringVar(value=self.asset.status), + } + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._save()) + self.after_idle(self._activate_modal) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + issued = bool(self.asset.current_holder_member_id) + fields = [ + ("Bezeichnung *", "label"), + ("Kategorie", "category"), + ("Inventarnummer", "inventory_number"), + ("Seriennummer", "serial_number"), + ("Kaution (EUR)", "deposit_amount_default"), + ] + self.entries: dict[str, ttk.Entry] = {} + for row, (label, key) in enumerate(fields): + ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) + state = "readonly" if key == "deposit_amount_default" and issued else "normal" + entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state) + entry.grid(row=row, column=1, sticky="ew", pady=5) + self.entries[key] = entry + ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) + status_values = [value for key, value in ( + ("available", "VERFUEGBAR"), + ("lost", "VERLOREN"), + ("retired", "AUSGEMUSTERT"), + )] + self.status_map = { + "VERFUEGBAR": "available", + "VERLOREN": "lost", + "AUSGEMUSTERT": "retired", + } + self.status_var = tk.StringVar( + value={ + "available": "VERFUEGBAR", + "lost": "VERLOREN", + "retired": "AUSGEMUSTERT", + }.get(self.asset.status, "VERFUEGBAR") + ) + self.status_box = ttk.Combobox( + frame, + textvariable=self.status_var, + values=status_values, + state="readonly" if not issued else "disabled", + width=35, + ) + self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5) + note_row = len(fields) + 1 + ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12)) + self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") + self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5) + self.notes_text.insert("1.0", self.asset.notes) + info_row = note_row + 1 + info_text = ( + "Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist." + if issued + else "Status kann hier auf verfuegbar, verloren oder ausgemustert gesetzt werden." + ) + ttk.Label(frame, text=info_text, style="Mono.TLabel").grid( + row=info_row, column=0, columnspan=2, sticky="w", pady=(4, 0) + ) + buttons = ttk.Frame(frame) + buttons.grid(row=info_row + 1, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left") + self.after_idle(lambda: self.entries["label"].focus_set()) + + def _activate_modal(self) -> None: + _activate_modal_window(self, self.entries.get("label")) + + def _save(self) -> None: + self.asset.label = self.variables["label"].get() + self.asset.category = self.variables["category"].get() + self.asset.inventory_number = self.variables["inventory_number"].get() + self.asset.serial_number = self.variables["serial_number"].get() + self.asset.notes = self.notes_text.get("1.0", "end-1c") + if not self.asset.current_holder_member_id: + self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get() + self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status) + try: + self.repository.save_asset(self.asset) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved(self.asset) + + +class IssueAssetDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + asset_id: str, + on_assigned: Callable[[Asset], None], + *, + preselected_member_id: str = "", + ): + super().__init__(master) + self.repository = repository + self.asset_id = asset_id + self.on_assigned = on_assigned + self.preselected_member_id = preselected_member_id + self.title("Asset ausgeben") + self.transient(master.winfo_toplevel()) + self.resizable(True, True) + self.search_var = tk.StringVar() + self.members = self.repository.list_members() + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._assign()) + self.after_idle(self._activate_modal) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + frame.columnconfigure(1, weight=1) + frame.rowconfigure(3, weight=1) + asset = self.repository.get_asset(self.asset_id) + ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid( + row=0, + column=1, + sticky="w", + pady=5, + ) + ttk.Label(frame, text="Suche").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) + self.search_entry = ttk.Entry(frame, textvariable=self.search_var, width=42) + self.search_entry.grid(row=1, column=1, sticky="ew", pady=5) + self.search_var.trace_add("write", lambda *_args: self._render_members()) + ttk.Label(frame, text="Mitglied").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12)) + self.member_tree = ttk.Treeview( + frame, + columns=("number", "name", "email"), + show="headings", + height=10, + ) + for key, title, width in ( + ("number", "Nummer", 120), + ("name", "Mitglied", 220), + ("email", "E-Mail", 220), + ): + self.member_tree.heading(key, text=title) + self.member_tree.column(key, width=width, anchor="w") + self.member_tree.grid(row=3, column=0, columnspan=2, sticky="nsew", pady=5) + self.member_tree.bind("", lambda _event: self._assign()) + self.member_tree.bind("", lambda _event: self._assign()) + self.member_tree.bind("<>", lambda _event: self._sync_selected_member_label()) + self.selected_member_var = tk.StringVar(value="Kein Mitglied ausgewählt.") + ttk.Label(frame, textvariable=self.selected_member_var, style="Mono.TLabel").grid( + row=4, column=0, columnspan=2, sticky="w", pady=(4, 0) + ) + buttons = ttk.Frame(frame) + buttons.grid(row=5, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button(buttons, text="Ausgeben", style="Accent.TButton", command=self._assign).pack(side="left") + self._render_members() + self.after_idle(self.search_entry.focus_set) + + def _activate_modal(self) -> None: + _activate_modal_window(self, self.search_entry) + + def _assign(self) -> None: + selected = self.member_tree.selection() + member_id = selected[0] if selected else "" + if not member_id: + messagebox.showerror("Ausgabe fehlgeschlagen", "Bitte ein Mitglied auswählen.", parent=self) + return + try: + asset = self.repository.assign_asset(self.asset_id, member_id) + except RepositoryError as exc: + messagebox.showerror("Ausgabe fehlgeschlagen", str(exc), parent=self) + return + self.destroy() + self.on_assigned(asset) + + def _render_members(self) -> None: + self.member_tree.delete(*self.member_tree.get_children()) + query = self.search_var.get().strip().casefold() + filtered = [ + member + for member in self.members + if not query or query in self._member_search_text(member) + ] + for member in filtered: + self.member_tree.insert( + "", + "end", + iid=member.member_id, + values=(member.member_number, member.display_name, member.email), + ) + selected_id = self.preselected_member_id if self.preselected_member_id else "" + if filtered: + target_id = ( + selected_id + if selected_id and any(member.member_id == selected_id for member in filtered) + else filtered[0].member_id + ) + self.member_tree.selection_set(target_id) + self.member_tree.focus(target_id) + self.member_tree.see(target_id) + self._sync_selected_member_label() + + def _sync_selected_member_label(self) -> None: + selected = self.member_tree.selection() + if not selected: + self.selected_member_var.set("Kein Mitglied ausgewählt.") + return + member = next((item for item in self.members if item.member_id == selected[0]), None) + if member is None: + self.selected_member_var.set("Kein Mitglied ausgewählt.") + return + self.selected_member_var.set(f"Ausgewählt: {self._member_label(member)}") + + @staticmethod + def _member_search_text(member: Member) -> str: + return " ".join( + value.casefold() + for value in ( + member.member_number, + member.first_name, + member.last_name, + member.nickname, + member.display_name, + member.email, + ) + if value + ) + + @staticmethod + def _member_label(member: Member) -> str: + prefix = member.member_number or member.member_id + name = member.display_name or member.member_id + return f"{prefix} · {name}" + + +class AssetClaimDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + asset_id: str, + member_id: str, + *, + preset_title: str, + preset_amount: str, + preset_description: str, + claim_type: str, + on_created: Callable[[str], None], + ): + super().__init__(master) + self.repository = repository + self.asset_id = asset_id + self.member_id = member_id + self.claim_type = claim_type + self.on_created = on_created + self.title("Forderung aus Asset anlegen") + self.transient(master.winfo_toplevel()) + self.resizable(False, False) + self.variables = { + "title": tk.StringVar(value=preset_title), + "amount": tk.StringVar(value=preset_amount), + "due_date": tk.StringVar(value=date.today().isoformat()), + } + self.preset_description = preset_description + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.bind("", lambda _event: self._create()) + self.after_idle(self._activate_modal) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + asset = self.repository.get_asset(self.asset_id) + member = self.repository.get_member(self.member_id) + ttk.Label(frame, text="Asset").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Label(frame, text=asset.label, style="TimelineHeader.TLabel").grid( + row=0, + column=1, + sticky="w", + pady=5, + ) + ttk.Label(frame, text="Mitglied").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Label(frame, text=f"{member.member_number or member.member_id} · {member.display_name}").grid( + row=1, column=1, sticky="w", pady=5 + ) + row_offset = 2 + for index, (label, key) in enumerate( + ( + ("Titel *", "title"), + ("Betrag (EUR) *", "amount"), + (f"Fällig am ({date_input_hint()}) *", "due_date"), + ) + ): + ttk.Label(frame, text=label).grid( + row=row_offset + index, + column=0, + sticky="w", + pady=5, + padx=(0, 12), + ) + ttk.Entry(frame, textvariable=self.variables[key], width=42).grid( + row=row_offset + index, column=1, sticky="ew", pady=5 + ) + ttk.Label(frame, text="Beschreibung").grid( + row=row_offset + 3, + column=0, + sticky="nw", + pady=5, + padx=(0, 12), + ) + self.description_text = tk.Text(frame, width=42, height=5, wrap="word") + self.description_text.grid(row=row_offset + 3, column=1, sticky="ew", pady=5) + self.description_text.insert("1.0", self.preset_description) + info = ( + "Negative Betraege werden als Gutschrift dokumentiert. " + "Die Auszahlung selbst wird im aktuellen CCMA-Stand nicht als eigener Workflow modelliert." + ) + ttk.Label(frame, text=info, style="Mono.TLabel").grid( + row=row_offset + 4, column=0, columnspan=2, sticky="w", pady=(4, 0) + ) + buttons = ttk.Frame(frame) + buttons.grid(row=row_offset + 5, column=0, columnspan=2, sticky="e", pady=(16, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button( + buttons, + text="Forderung anlegen", + style="Accent.TButton", + command=self._create, + ).pack(side="left") + self.after_idle(lambda: frame.focus_set()) + + def _activate_modal(self) -> None: + _activate_modal_window(self) + + def _create(self) -> None: + try: + result = self.repository.create_manual_claim( + self.member_id, + title=self.variables["title"].get(), + amount=self.variables["amount"].get(), + due_date=self.variables["due_date"].get(), + description=self.description_text.get("1.0", "end-1c"), + claim_type=self.claim_type, + references={"asset_id": self.asset_id}, + ) + except RepositoryError as exc: + messagebox.showerror("Forderung konnte nicht angelegt werden", str(exc), parent=self) + return + self.destroy() + self.on_created(str(result["claim"]["claim_id"])) + + +class IntegrityWarningDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + *, + title: str, + warnings: list[str], + on_confirm: Callable[[], None], + ): + super().__init__(master) + self.on_confirm = on_confirm + self.title(title) + self.transient(master.winfo_toplevel()) + self.resizable(False, False) + self.warnings = warnings + self._build_ui() + self.bind("", lambda _event: self.destroy()) + self.after_idle(self._activate_modal) + + def _build_ui(self) -> None: + frame = ttk.Frame(self, padding=18) + frame.pack(fill="both", expand=True) + message = ( + "ACHTUNG: Die zugehörigen JSON-Dateien wurden vermutlich extern geändert.\n\n" + "Haben Sie alle Daten geprüft und soll der Hash jetzt aktualisiert werden?" + ) + ttk.Label(frame, text=message, justify="left").grid(row=0, column=0, sticky="w") + ttk.Label( + frame, + text="\n".join(f"• {item}" for item in self.warnings), + style="Warning.TLabel", + justify="left", + ).grid( + row=1, column=0, sticky="w", pady=(12, 0) + ) + buttons = ttk.Frame(frame) + buttons.grid(row=2, column=0, sticky="e", pady=(18, 0)) + ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button( + buttons, + text="Ja, bestätigen", + style="Accent.TButton", + command=self._confirm, + ).pack(side="left") + + def _activate_modal(self) -> None: + _activate_modal_window(self) + + def _confirm(self) -> None: + self.destroy() + self.on_confirm() diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index a3cfd5a..51b2145 100644 --- a/src/ccma/ui/main_window.py +++ b/src/ccma/ui/main_window.py @@ -5,16 +5,17 @@ from tkinter import messagebox, ttk from ccma import __version__ from ccma.config import AppConfig -from ccma.domain.models import HousekeeperFinding, Member +from ccma.domain.models import Asset, HousekeeperFinding, Member from ccma.services.housekeeper import Housekeeper from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.asset_tab import AssetTab from ccma.ui.claim_tab import ClaimTab -from ccma.ui.dialogs import NewMemberDialog +from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog from ccma.ui.icons import IconStore from ccma.ui.member_tab import MemberTab from ccma.ui.options_dialog import OptionsDialog from ccma.ui.theme import load_theme -from ccma.ui.work_tabs import DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab +from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab class TabManager: @@ -175,6 +176,17 @@ class MainWindow(ttk.Frame): ) members_button.image = members_icon members_button.pack(side="left", padx=(0, 6)) + assets_icon = self.icons.get("key-variant", 24) or self.icons.get("package-variant-closed", 24) + assets_button = ttk.Button( + actions, + text="Inventar", + image=assets_icon, + compound="top", + width=14, + command=self.open_assets, + ) + assets_button.image = assets_icon + assets_button.pack(side="left", padx=(0, 6)) new_icon = self.icons.get("account-plus", 24) new_button = ttk.Button( actions, @@ -278,6 +290,9 @@ class MainWindow(ttk.Frame): on_close=lambda: self.tabs.close(key), on_changed=self.refresh_overview, on_open_claim=self.open_claim, + on_open_assets=self.open_assets, + on_open_asset=self.open_asset, + on_return_asset=self.return_asset, ) self.tabs.add( key, @@ -312,6 +327,34 @@ class MainWindow(ttk.Frame): icon_name="receipt", ) + def open_asset(self, asset_id: str) -> None: + key = f"asset:{asset_id}" + if self.tabs.focus(key): + return + try: + asset = self.repository.get_asset(asset_id) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht geöffnet werden", str(exc), parent=self) + return + tab = AssetTab( + self.notebook, + self.repository, + asset_id, + on_close=lambda: self.tabs.close(key), + on_changed=self.refresh_overview, + on_open_member=self.open_member, + on_issue_asset=self.issue_asset, + on_return_asset=self.return_asset, + on_open_claim=self.open_claim, + ) + self.tabs.add( + key, + tab, + asset.label or asset.asset_id, + image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16), + icon_name="key-variant", + ) + def _claim_changed(self, member_id: str) -> None: member_tab = self.tabs.tabs.get(f"member:{member_id}") if isinstance(member_tab, MemberTab) and member_tab.winfo_exists(): @@ -336,14 +379,91 @@ class MainWindow(ttk.Frame): icon_name="account-group", ) + def open_assets(self) -> None: + key = "assets" + if self.tabs.focus(key): + return + tab = AssetsTab( + self.notebook, + self.repository.list_assets(), + self._member_reference_label, + self.new_asset, + self.open_asset, + self.edit_asset, + self.issue_asset, + self.return_asset, + lambda: self.tabs.close(key), + ) + self.tabs.add( + key, + tab, + "Inventar", + image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16), + icon_name="key-variant", + ) + def new_member(self) -> None: NewMemberDialog(self, self.repository, self._member_created) + def new_asset(self) -> None: + NewAssetDialog(self, self.repository, self._asset_created) + def _member_created(self, member: Member) -> None: self.refresh_overview() self.open_member(member.member_id) self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.") + def _asset_created(self, asset: Asset) -> None: + self.refresh_overview() + self.open_assets() + self.status_var.set(f"Asset angelegt: {asset.label}") + + def issue_asset(self, asset_id: str, preselected_member_id: str = "") -> None: + try: + IssueAssetDialog( + self, + self.repository, + asset_id, + self._asset_changed, + preselected_member_id=preselected_member_id, + ) + except (RepositoryError, tk.TclError) as exc: + messagebox.showerror("Asset konnte nicht ausgegeben werden", str(exc), parent=self) + + def edit_asset(self, asset_id: str) -> None: + try: + EditAssetDialog( + self, + self.repository, + asset_id, + self._asset_changed, + ) + except (RepositoryError, tk.TclError) as exc: + messagebox.showerror("Asset konnte nicht bearbeitet werden", str(exc), parent=self) + + def return_asset(self, asset_id: str) -> None: + try: + asset = self.repository.get_asset(asset_id) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht geladen werden", str(exc), parent=self) + return + if not messagebox.askyesno( + "Asset zurücknehmen", + f"{asset.label}\n\nDieses Asset wirklich als zurückgegeben markieren?", + parent=self, + ): + return + try: + asset = self.repository.return_asset(asset_id) + except RepositoryError as exc: + messagebox.showerror("Rückgabe fehlgeschlagen", str(exc), parent=self) + return + self._asset_changed(asset) + + def _asset_changed(self, asset: Asset) -> None: + self.refresh_overview() + self.status_var.set(f"Asset aktualisiert: {asset.label}") + def open_housekeeper(self) -> None: key = "housekeeper" if self.tabs.focus(key): @@ -351,7 +471,7 @@ class MainWindow(ttk.Frame): tab = HousekeeperTab( self.notebook, self.findings, - self.open_member, + self._open_housekeeper_target, self.run_housekeeper, self.delete_housekeeper_task, lambda: self.tabs.close(key), @@ -383,6 +503,14 @@ class MainWindow(ttk.Frame): members_tab = self.tabs.tabs.get("members") if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): members_tab.refresh(self.repository.list_members()) + assets_tab = self.tabs.tabs.get("assets") + if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists(): + assets_tab.refresh(self.repository.list_assets()) + for key, widget in self.tabs.tabs.items(): + if key.startswith("member:") and isinstance(widget, MemberTab) and widget.winfo_exists(): + widget.refresh() + if key.startswith("asset:") and isinstance(widget, AssetTab) and widget.winfo_exists(): + widget.refresh() if self.housekeeper_button and self.housekeeper_button.winfo_exists(): self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})") @@ -417,8 +545,30 @@ class MainWindow(ttk.Frame): self.tabs.refresh_icons(self.icons) def _show_validation_warning(self) -> None: + display_errors = [ + item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item + ] + if not display_errors: + return messagebox.showwarning( "Datenprüfung", - "Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]), + "Der Store enthält ungültige Akten:\n\n" + "\n".join(display_errors[:12]), parent=self, ) + + def _open_housekeeper_target(self, finding: HousekeeperFinding) -> None: + if finding.target_type == "asset" and finding.asset_id: + self.open_asset(finding.asset_id) + return + if finding.member_id: + self.open_member(finding.member_id) + + def _member_reference_label(self, member_id: str) -> str: + if not member_id: + return "—" + try: + member = self.repository.get_member(member_id) + except RepositoryError: + return member_id + prefix = member.member_number or member.member_id + return f"{prefix} · {member.display_name or member.member_id}" diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index ee7dd71..e1ad3f5 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -8,12 +8,35 @@ from tkinter import messagebox, ttk from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.dates import age_label, date_input_hint, format_date_for_display +from ccma.domain.models import ASSET_STATUS_LABELS, Event from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS -from ccma.domain.models import Event from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.dialogs import IntegrityWarningDialog from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key +from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage +from ccma.ui.scrolling import ScrollableFrame + +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): @@ -25,6 +48,9 @@ class MemberTab(ttk.Frame): on_close: Callable[[], None], on_changed: Callable[[], None], on_open_claim: Callable[[str, str], None], + on_open_assets: Callable[[], None], + on_open_asset: Callable[[str], None], + on_return_asset: Callable[[str], None], ): super().__init__(master, padding=12) self.repository = repository @@ -32,31 +58,51 @@ class MemberTab(ttk.Frame): self.on_close = on_close self.on_changed = on_changed self.on_open_claim = on_open_claim + self.on_open_assets = on_open_assets + self.on_open_asset = on_open_asset + self.on_return_asset = on_return_asset self.member = repository.get_member(member_id) self.variables: dict[str, tk.Variable] = {} + self._field_sections: dict[str, str] = {} + self._dirty_sections: set[str] = set() + self._form_tabs: dict[str, str] = {} + self._form_tab_titles: dict[str, str] = {} + self._loading = False + self.notes_text: tk.Text | None = None self._build_ui() self.refresh() def _build_ui(self) -> None: self.columnconfigure(0, weight=1) - self.rowconfigure(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) + header.columnconfigure(1, weight=0) self.title_var = tk.StringVar() self.status_var = tk.StringVar() - ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid( + self.id_var = tk.StringVar() + title_column = ttk.Frame(header) + title_column.grid(row=0, column=0, sticky="ew") + title_column.columnconfigure(0, weight=1) + ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid( row=0, column=0, sticky="w" ) - ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid( + ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid( row=1, column=0, sticky="w", pady=(3, 0) ) - ttk.Button(header, text="Tab schließen", command=self.on_close).grid( - row=0, column=1, rowspan=2, sticky="e" + ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid( + row=2, column=0, sticky="w", pady=(3, 0) ) + ttk.Button(header, text="Tab schließen", command=self._close).grid( + row=0, column=1, sticky="ne", padx=(12, 0) + ) + self.messages = MessageBannerList(self) + self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + self.messages.grid_remove() self.pane = ttk.Panedwindow(self, orient="horizontal") - self.pane.grid(row=1, column=0, sticky="nsew") + self.pane.grid(row=2, column=0, sticky="nsew") self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) self.pane.add(self.details_pane, weight=2) @@ -79,24 +125,38 @@ class MemberTab(ttk.Frame): def _build_details(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) - parent.rowconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) notebook = ttk.Notebook(parent) - notebook.grid(row=0, column=0, sticky="nsew") - data_tab = ttk.Frame(notebook, padding=16) - address_tab = ttk.Frame(notebook, padding=16) - banking_tab = ttk.Frame(notebook, padding=16) + self.details_notebook = notebook + notebook.grid(row=1, column=0, sticky="nsew") + data_tab = self._create_form_tab(notebook, "data", "Stammdaten") + address_tab = self._create_form_tab(notebook, "address", "Anschrift") + banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA") + actions = ttk.Frame(parent) + actions.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + actions.columnconfigure(0, weight=1) + ttk.Button( + actions, + text="Mitgliedsdaten speichern", + style="Accent.TButton", + command=self._save, + ).grid(row=0, column=0, sticky="e") contribution_tab = ttk.Frame(notebook, padding=16) + assets_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16) - notebook.add(data_tab, text="Stammdaten") - notebook.add(address_tab, text="Anschrift") - notebook.add(banking_tab, text="Bank / SEPA") notebook.add(contribution_tab, text="Forderungen") + notebook.add(assets_tab, text="Assets") notebook.add(documents_tab, text="Dokumente") fields = [ + ("UUID / Ordner-ID", "member_id"), ("Mitgliedsnummer", "member_number"), ("Vorname", "first_name"), ("Nachname", "last_name"), + ("Nickname", "nickname"), ("E-Mail-Adresse", "email"), ("Telefonnummer", "phone"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"), @@ -105,7 +165,7 @@ class MemberTab(ttk.Frame): ] for row, (label, key) in enumerate(fields): variable = tk.StringVar() - self.variables[key] = variable + self._add_variable(key, variable, "data") ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) if key == "birth_date": birth_row = ttk.Frame(data_tab) @@ -120,10 +180,11 @@ 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 in {"member_id", "member_number"} else "normal" + ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid( row=row, column=1, sticky="ew", pady=5 ) - self.variables["status"] = tk.StringVar() + self._add_variable("status", tk.StringVar(), "data") ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Combobox( data_tab, @@ -132,17 +193,13 @@ 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) + self.notes_text.bind("<>", lambda _event: self._mark_dirty_from_text("data"), add="+") 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) - ) address_fields = ( ("Straße und Hausnummer", "street"), @@ -153,14 +210,11 @@ class MemberTab(ttk.Frame): ) address_tab.columnconfigure(1, weight=1) for row, (label, key) in enumerate(address_fields): - self.variables[key] = tk.StringVar() + self._add_variable(key, tk.StringVar(), "address") ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5) ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid( row=row, column=1, sticky="ew", pady=5 ) - ttk.Button( - address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save - ).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0)) banking_fields = ( ("Kontoinhaber", "account_holder"), @@ -172,22 +226,18 @@ class MemberTab(ttk.Frame): ) banking_tab.columnconfigure(1, weight=1) for row, (label, key) in enumerate(banking_fields): - self.variables[key] = tk.StringVar() + self._add_variable(key, tk.StringVar(), "banking") ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5) ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid( row=row, column=1, sticky="ew", pady=5 ) - self.variables["mandate_active"] = tk.BooleanVar() + self._add_variable("mandate_active", tk.BooleanVar(), "banking") ttk.Checkbutton( banking_tab, text="SEPA-Lastschriftmandat ist aktiv", variable=self.variables["mandate_active"], style="Switch", ).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5)) - ttk.Button( - banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save - ).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0)) - contribution_tab.columnconfigure(0, weight=1) contribution_tab.rowconfigure(1, weight=1) self.contribution_summary = tk.StringVar() @@ -197,18 +247,53 @@ 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()) self.claims.bind("", lambda _event: self._open_selected_claim()) + assets_tab.columnconfigure(0, weight=1) + assets_tab.rowconfigure(1, weight=1) + self.assets_summary = tk.StringVar() + ttk.Label(assets_tab, textvariable=self.assets_summary, style="Mono.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.assets_tree = ttk.Treeview( + assets_tab, + columns=("label", "category", "inventory_number", "status"), + show="headings", + ) + for key, title, width in ( + ("label", "Bezeichnung", 240), + ("category", "Kategorie", 140), + ("inventory_number", "Inventarnummer", 140), + ("status", "Status", 140), + ): + self.assets_tree.heading(key, text=title) + self.assets_tree.column(key, width=width, anchor="w") + self.assets_tree.grid(row=1, column=0, sticky="nsew") + self.assets_tree.bind("", lambda _event: self._open_selected_asset()) + self.assets_tree.bind("", lambda _event: self._open_selected_asset()) + asset_actions = ttk.Frame(assets_tab) + asset_actions.grid(row=2, column=0, sticky="e", pady=(10, 0)) + ttk.Button(asset_actions, text="Inventar öffnen", command=self.on_open_assets).pack( + side="left", padx=(0, 8) + ) + ttk.Button(asset_actions, text="Asset öffnen", command=self._open_selected_asset).pack( + side="left", padx=(0, 8) + ) + ttk.Button( + asset_actions, + text="Ausgewähltes Asset zurücknehmen", + command=self._return_selected_asset, + ).pack( + side="left", + ) + documents_tab.columnconfigure(0, weight=1) documents_tab.rowconfigure(1, weight=1) document_buttons = ttk.Frame(documents_tab) @@ -247,6 +332,53 @@ class MemberTab(ttk.Frame): self.documents.bind("", lambda _event: self._open_selected_document()) self.document_paths: dict[str, Path] = {} + def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame: + tab = ttk.Frame(notebook) + tab.columnconfigure(0, weight=1) + tab.rowconfigure(0, weight=1) + scroller = ScrollableFrame(tab, padding=16) + scroller.grid(row=0, column=0, sticky="nsew") + notebook.add(tab, text=title) + self._form_tabs[section] = str(tab) + self._form_tab_titles[section] = title + return scroller.content + + def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None: + self.variables[key] = variable + self._field_sections[key] = section + variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target)) + + def _mark_dirty(self, section: str) -> None: + if self._loading: + return + self._dirty_sections.add(section) + self._refresh_dirty_tabs() + + def _mark_dirty_from_text(self, section: str) -> None: + if self.notes_text is None or not self.notes_text.edit_modified(): + return + self.notes_text.edit_modified(False) + self._mark_dirty(section) + + def _refresh_dirty_tabs(self) -> None: + for section, tab_id in self._form_tabs.items(): + title = self._form_tab_titles[section] + suffix = " *" if section in self._dirty_sections else "" + self.details_notebook.tab(tab_id, text=f"{title}{suffix}") + + def _clear_dirty(self) -> None: + self._dirty_sections.clear() + self._refresh_dirty_tabs() + + def _close(self) -> None: + if self._dirty_sections and not messagebox.askokcancel( + "Ungespeicherte Änderungen", + "Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?", + parent=self, + ): + return + self.on_close() + def _build_timeline(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(1, weight=1) @@ -271,9 +403,23 @@ class MemberTab(ttk.Frame): ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) def refresh(self) -> None: + self._loading = True self.member = self.repository.get_member(self.member_id) self.title_var.set(f"{self.member.member_number or '—'} · {self.member.display_name}") self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper())) + self.id_var.set(f"UUID: {self.member.member_id}") + warnings = self.repository.member_hash_warnings(self.member_id) + self.messages.set_messages( + [ + TabMessage( + "warning", + "WARNUNG: " + " | ".join(warnings), + MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner), + ) + ] + if warnings + else [] + ) date_fields = { "birth_date", "accepted_at", @@ -287,8 +433,15 @@ 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.notes_text.edit_modified(False) + self._loading = False + self._clear_dirty() self._refresh_events() self._refresh_contributions() + self._refresh_assets() self._refresh_documents() def _refresh_events(self) -> None: @@ -308,7 +461,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 +483,25 @@ 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-"): @@ -356,21 +534,68 @@ class MemberTab(ttk.Frame): ), ) + def _refresh_assets(self) -> None: + self.assets_tree.delete(*self.assets_tree.get_children()) + assets = self.repository.list_member_assets(self.member_id) + self.assets_summary.set(f"{len(assets)} ausgegebene Assets") + for asset in assets: + self.assets_tree.insert( + "", + "end", + iid=asset.asset_id, + values=( + asset.label, + asset.category, + asset.inventory_number, + ASSET_STATUS_LABELS.get(asset.status, asset.status), + ), + ) + def _save(self) -> None: + warnings = self.repository.member_hash_warnings(self.member_id) + if warnings: + self._confirm_integrity_and_then(self._save_confirmed) + return + self._save_confirmed() + + def _save_confirmed(self) -> None: for key, variable in self.variables.items(): raw_value = variable.get() value = raw_value.strip() if isinstance(raw_value, str) else raw_value if key == "status": value = storage_key(STATUS_LABELS, value) setattr(self.member, key, value) + if self.notes_text is not None: + self.member.notes = self.notes_text.get("1.0", "end-1c").strip() try: self.repository.save_member(self.member) + self.repository.refresh_member_record_hashes(self.member_id) except RepositoryError as exc: messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self) return self.refresh() self.on_changed() + def _confirm_integrity_banner(self) -> None: + self._confirm_integrity_and_then(self._refresh_hashes_only) + + def _refresh_hashes_only(self) -> None: + self.repository.refresh_member_record_hashes(self.member_id) + self.refresh() + self.on_changed() + + def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None: + warnings = self.repository.member_hash_warnings(self.member_id) + if not warnings: + callback() + return + IntegrityWarningDialog( + self, + title="Externe Änderungen bestätigen", + warnings=warnings, + on_confirm=callback, + ) + def _add_comment(self) -> None: text = self.comment_var.get().strip() if not text: @@ -389,6 +614,16 @@ class MemberTab(ttk.Frame): path = self.repository.members_root / self.member_id / "files" self._open_path(path) + def _return_selected_asset(self) -> None: + selected = self.assets_tree.selection() + if selected: + self.on_return_asset(selected[0]) + + def _open_selected_asset(self) -> None: + selected = self.assets_tree.selection() + if selected: + self.on_open_asset(selected[0]) + def _open_selected_document(self) -> None: selected = self.documents.selection() if selected and selected[0] in self.document_paths: diff --git a/src/ccma/ui/messages.py b/src/ccma/ui/messages.py new file mode 100644 index 0000000..15fc270 --- /dev/null +++ b/src/ccma/ui/messages.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from tkinter import ttk + +MessageType = str + + +@dataclass(frozen=True) +class MessageAction: + label: str + callback: Callable[[], None] + + +@dataclass(frozen=True) +class TabMessage: + message_type: MessageType + text: str + action: MessageAction | None = None + + +_TYPE_STYLES = { + "error": "Error", + "notification": "Notification", + "warning": "Warning", + "info": "Info", +} + + +class MessageBannerList(ttk.Frame): + def __init__(self, master: tk.Misc): + super().__init__(master) + self.columnconfigure(0, weight=1) + self._messages: list[TabMessage] = [] + + def set_messages(self, messages: Iterable[TabMessage]) -> None: + self._messages = [message for message in messages if message.text.strip()] + self._render() + if self._messages: + self.grid() + else: + self.grid_remove() + + def _render(self) -> None: + for child in self.winfo_children(): + child.destroy() + for row_index, message in enumerate(self._messages): + style_name = _message_style_name(message.message_type) + banner = ttk.Frame(self, style=f"Message{style_name}Border.TFrame", padding=1) + banner.grid(row=row_index, column=0, sticky="ew", pady=(0, 6)) + banner.columnconfigure(0, weight=1) + body = ttk.Frame(banner, style=f"Message{style_name}.TFrame", padding=(10, 8)) + body.grid(row=0, column=0, sticky="ew") + body.columnconfigure(0, weight=1) + label = ttk.Label( + body, + text=message.text, + style=f"Message{style_name}.TLabel", + wraplength=900, + justify="left", + ) + label.grid(row=0, column=0, sticky="ew") + body.bind( + "", + lambda event, target=label: target.configure(wraplength=max(240, event.width - 180)), + add="+", + ) + if message.action is not None: + ttk.Button( + body, + text=message.action.label, + command=message.action.callback, + ).grid(row=0, column=1, sticky="e", padx=(12, 0)) + + +def _message_style_name(message_type: MessageType) -> str: + return _TYPE_STYLES.get(message_type.casefold(), "Info") 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/scrolling.py b/src/ccma/ui/scrolling.py new file mode 100644 index 0000000..6fc0cbc --- /dev/null +++ b/src/ccma/ui/scrolling.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + + +class ScrollableFrame(ttk.Frame): + def __init__(self, master: tk.Misc, *, padding: int | tuple[int, ...] = 0): + super().__init__(master) + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + self.canvas = tk.Canvas(self, highlightthickness=0, borderwidth=0) + self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview) + self.content = ttk.Frame(self.canvas, padding=padding) + self._content_window = self.canvas.create_window((0, 0), window=self.content, anchor="nw") + self.canvas.configure(yscrollcommand=self.scrollbar.set) + self.canvas.grid(row=0, column=0, sticky="nsew") + self.content.bind("", self._update_scrollregion, add="+") + self.canvas.bind("", self._fit_content_width, add="+") + self.bind("", self._bind_mousewheel, add="+") + self.bind("", self._unbind_mousewheel, add="+") + self.content.bind("", self._bind_mousewheel, add="+") + self.content.bind("", self._unbind_mousewheel, add="+") + self.after_idle(self._apply_canvas_background) + + def _apply_canvas_background(self) -> None: + background = ttk.Style(self).lookup("TFrame", "background") + if background: + self.canvas.configure(background=background) + + def _update_scrollregion(self, _event: tk.Event | None = None) -> None: + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + self._sync_scrollbar() + + def _fit_content_width(self, event: tk.Event) -> None: + self.canvas.itemconfigure(self._content_window, width=event.width) + self._sync_scrollbar() + + def _sync_scrollbar(self) -> None: + bounds = self.canvas.bbox("all") + if not bounds: + self.scrollbar.grid_remove() + return + content_height = bounds[3] - bounds[1] + canvas_height = self.canvas.winfo_height() + if canvas_height > 1 and content_height > canvas_height: + self.scrollbar.grid(row=0, column=1, sticky="ns") + else: + self.scrollbar.grid_remove() + + def _bind_mousewheel(self, _event: tk.Event | None = None) -> None: + self.canvas.bind_all("", self._on_mousewheel, add="+") + self.canvas.bind_all("", self._on_mousewheel, add="+") + self.canvas.bind_all("", self._on_mousewheel, add="+") + + def _unbind_mousewheel(self, _event: tk.Event | None = None) -> None: + self.canvas.unbind_all("") + self.canvas.unbind_all("") + self.canvas.unbind_all("") + + def _on_mousewheel(self, event: tk.Event) -> None: + if getattr(event, "num", None) == 4: + delta = -1 + elif getattr(event, "num", None) == 5: + delta = 1 + else: + delta = -1 * int(getattr(event, "delta", 0) / 120) + self.canvas.yview_scroll(delta, "units") diff --git a/src/ccma/ui/sections.py b/src/ccma/ui/sections.py new file mode 100644 index 0000000..9d0fdb1 --- /dev/null +++ b/src/ccma/ui/sections.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + + +def titled_frame(parent: tk.Misc, title: str) -> ttk.LabelFrame: + frame = ttk.LabelFrame(parent, padding=(12, 10)) + frame.configure(labelwidget=ttk.Label(frame, text=title, style="TimelineHeader.TLabel")) + return frame diff --git a/src/ccma/ui/theme.py b/src/ccma/ui/theme.py index da3e929..01f614f 100644 --- a/src/ccma/ui/theme.py +++ b/src/ccma/ui/theme.py @@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None: accent = "#00d084" if dark else "#087f5b" warning = "#ffb454" danger = "#ff6b6b" + message_styles = { + "Error": ( + "#5c2528" if dark else "#d04242", + "#3a1719" if dark else "#fde8e8", + "#ffd7d7" if dark else "#8a1f1f", + ), + "Warning": ( + "#6c4b19" if dark else "#d69b19", + "#3a2a12" if dark else "#fff4cc", + "#ffe2a8" if dark else "#7a4f00", + ), + "Info": ( + "#204f79" if dark else "#5b9bd8", + "#132c45" if dark else "#e5f1ff", + "#cde6ff" if dark else "#174a7c", + ), + "Notification": ( + "#1f5a3a" if dark else "#57ad75", + "#123222" if dark else "#e4f7ec", + "#c9f2dc" if dark else "#1f6b3d", + ), + } style.configure("Ribbon.TFrame", padding=(12, 9)) style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold")) style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold")) @@ -75,3 +97,15 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None: fieldbackground=background, foreground=foreground, ) + for name, (message_border, message_background, message_foreground) in message_styles.items(): + style.configure(f"Message{name}Border.TFrame", background=message_border) + style.configure( + f"Message{name}.TFrame", + background=message_background, + ) + style.configure( + f"Message{name}.TLabel", + background=message_background, + foreground=message_foreground, + font=("TkDefaultFont", 10), + ) diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index e843c58..26b2c1c 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -4,7 +4,97 @@ from collections.abc import Callable 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.domain.models import ( + ASSET_STATUS_LABELS, + MEMBERSHIP_STATUS_LABELS, + Asset, + HousekeeperFinding, + Member, +) +from ccma.ui.labels import storage_key +from ccma.ui.sections import titled_frame + +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" +ASSET_FILTER_ALL = "Alle" +ASSET_TABLE_COLUMNS = ( + ("label", "Bezeichnung", 260), + ("category", "Kategorie", 140), + ("inventory_number", "Inventarnummer", 140), + ("status", "Status", 140), + ("holder", "Mitglied", 240), +) + + +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) + + +def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame: + return titled_frame(parent, "// FILTER") + + +def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str: + if column == "label": + return asset.label + if column == "category": + return asset.category + if column == "inventory_number": + return asset.inventory_number + if column == "status": + return ASSET_STATUS_LABELS.get(asset.status, asset.status) + if column == "holder": + return holder_label + return "" + + +def _filter_assets(assets: list[Asset], status_filter: str) -> list[Asset]: + if status_filter == "all": + return list(assets) + return [asset for asset in assets if asset.status == status_filter] class DashboardTab(ttk.Frame): @@ -82,11 +172,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 +197,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 +230,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,60 +238,300 @@ 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 = _filter_label_frame(self) + filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + filter_row = ttk.Frame(filters) + filter_row.grid(row=0, column=0, sticky="w") 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(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) + self.status_filter = ttk.Combobox( + filter_row, + textvariable=self.status_filter_var, + state="readonly", + values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()], + width=28, + ) + self.status_filter.grid(row=0, column=1, 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: self.on_open(selected[0]) +class AssetsTab(ttk.Frame): + def __init__( + self, + master: tk.Misc, + assets: list[Asset], + resolve_holder_label: Callable[[str], str], + on_new: Callable[[], None], + on_open: Callable[[str], None], + on_edit: Callable[[str], None], + on_issue: Callable[[str], None], + on_return: Callable[[str], None], + on_close: Callable[[], None], + ): + super().__init__(master, padding=12) + self.assets = assets + self.resolve_holder_label = resolve_holder_label + self.on_new = on_new + self.on_open = on_open + self.on_edit = on_edit + self.on_issue = on_issue + self.on_return = on_return + self.on_close = on_close + self.sort_column = "label" + self.sort_descending = False + self.status_filter_var = tk.StringVar(value=ASSET_FILTER_ALL) + self._build_ui() + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(2, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text="INVENTAR", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + self.count_var = tk.StringVar() + ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w") + button_row = ttk.Frame(header) + button_row.grid(row=0, column=1, rowspan=2, sticky="e") + ttk.Button(button_row, text="Neues Asset", command=self.on_new).pack(side="left", padx=(0, 8)) + ttk.Button(button_row, text="Tab schließen", command=self.on_close).pack(side="left") + filters = _filter_label_frame(self) + filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + filter_row = ttk.Frame(filters) + filter_row.grid(row=0, column=0, sticky="w") + ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) + self.status_filter = ttk.Combobox( + filter_row, + textvariable=self.status_filter_var, + state="readonly", + values=[ASSET_FILTER_ALL, *ASSET_STATUS_LABELS.values()], + width=22, + ) + self.status_filter.grid(row=0, column=1, sticky="w") + self.status_filter.bind("<>", lambda _event: self._render_assets()) + self.tree = ttk.Treeview( + self, + columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS), + show="headings", + ) + for key, title, width in ASSET_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=2, column=0, sticky="nsew") + self.tree.bind("", self._open_selected) + self.tree.bind("<>", lambda _event: self._update_actions()) + actions = ttk.Frame(self) + actions.grid(row=3, column=0, sticky="e", pady=(10, 0)) + self.edit_button = ttk.Button( + actions, + text="Bearbeiten", + command=self._edit_selected, + state="disabled", + ) + self.edit_button.pack(side="left", padx=(0, 8)) + self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled") + self.open_button.pack(side="left", padx=(0, 8)) + self.issue_button = ttk.Button( + actions, + text="Ausgeben", + command=self._issue_selected, + state="disabled", + ) + self.issue_button.pack(side="left", padx=(0, 8)) + self.return_button = ttk.Button( + actions, + text="Zurücknehmen", + command=self._return_selected, + state="disabled", + ) + self.return_button.pack(side="left") + self.refresh(self.assets) + + def refresh(self, assets: list[Asset]) -> None: + self.assets = assets + self._render_assets() + + def _render_assets(self) -> None: + self.tree.delete(*self.tree.get_children()) + status_filter = _selected_asset_filter(self.status_filter_var.get()) + filtered_assets = _filter_assets(self.assets, status_filter) + sorted_assets = sorted( + filtered_assets, + key=lambda asset: _asset_table_value( + asset, + self.sort_column, + self.resolve_holder_label(asset.current_holder_member_id), + ).casefold(), + reverse=self.sort_descending, + ) + if len(filtered_assets) == len(self.assets): + self.count_var.set(f"{len(filtered_assets)} Assets") + else: + self.count_var.set(f"{len(filtered_assets)} / {len(self.assets)} Assets") + self._update_tree_headings() + for asset in sorted_assets: + self.tree.insert( + "", + "end", + iid=asset.asset_id, + values=( + asset.label, + asset.category, + asset.inventory_number, + ASSET_STATUS_LABELS.get(asset.status, asset.status), + self.resolve_holder_label(asset.current_holder_member_id), + ), + ) + self._update_actions() + + 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_assets() + + def _update_tree_headings(self) -> None: + for key, title, _width in ASSET_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 _selected_asset_id(self) -> str: + selected = self.tree.selection() + return selected[0] if selected else "" + + def _selected_asset(self) -> Asset | None: + selected_asset_id = self._selected_asset_id() + return next((asset for asset in self.assets if asset.asset_id == selected_asset_id), None) + + def _update_actions(self) -> None: + asset = self._selected_asset() + if asset is None: + self.edit_button.configure(state="disabled") + self.open_button.configure(state="disabled") + self.issue_button.configure(state="disabled") + self.return_button.configure(state="disabled") + return + self.edit_button.configure(state="normal") + self.open_button.configure(state="normal") + self.issue_button.configure(state="normal" if asset.status == "available" else "disabled") + self.return_button.configure(state="normal" if asset.current_holder_member_id else "disabled") + + def _open_selected(self, _event: tk.Event | None = None) -> None: + asset_id = self._selected_asset_id() + if not asset_id: + asset_id = self.tree.focus() + if asset_id: + self.on_open(asset_id) + + def _edit_selected(self, _event: tk.Event | None = None) -> None: + asset_id = self._selected_asset_id() + if not asset_id: + asset_id = self.tree.focus() + if asset_id: + self.on_edit(asset_id) + + def _issue_selected(self, _event: tk.Event | None = None) -> None: + asset_id = self._selected_asset_id() + if not asset_id: + asset_id = self.tree.focus() + if asset_id: + self.on_issue(asset_id) + + def _return_selected(self) -> None: + asset_id = self._selected_asset_id() + if asset_id: + self.on_return(asset_id) + + class HousekeeperTab(ttk.Frame): def __init__( self, master: tk.Misc, findings: list[HousekeeperFinding], - on_open_member: Callable[[str], None], + on_open_target: Callable[[HousekeeperFinding], None], on_refresh: Callable[[], list[HousekeeperFinding]], on_delete: Callable[[str], list[HousekeeperFinding]], on_close: Callable[[], None], ): super().__init__(master, padding=12) self.findings = findings - self.on_open_member = on_open_member + self.on_open_target = on_open_target self.on_refresh = on_refresh self.on_delete = on_delete self.on_close = on_close @@ -303,11 +641,15 @@ class HousekeeperTab(ttk.Frame): def _open_selected(self) -> None: selected = self.tree.selection() if selected: - self.on_open_member(self.findings[int(selected[0])].member_id) + self.on_open_target(self.findings[int(selected[0])]) def _finding_details(finding: HousekeeperFinding) -> str: lines = [f"{finding.severity.upper()} · {finding.code}", finding.title] + if finding.target_type == "asset" and finding.asset_id: + lines.append(f"Asset: {finding.asset_id}") + elif finding.member_id: + lines.append(f"Mitglied: {finding.member_id}") if finding.key: lines.append(f"Key: {finding.key}") if finding.due_date: @@ -315,3 +657,9 @@ def _finding_details(finding: HousekeeperFinding) -> str: if finding.detail: lines.extend(("", finding.detail)) return "\n".join(lines) + + +def _selected_asset_filter(label: str) -> str: + if label == ASSET_FILTER_ALL: + return "all" + return storage_key(ASSET_STATUS_LABELS, label) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 53cca0a..3b33456 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,9 +1,9 @@ -from ccma import __version__ from ccma.ui.changelog_view import load_changelog -def test_changelog_contains_current_version() -> None: +def test_changelog_contains_release_entry() -> None: entries = load_changelog() assert entries - assert entries[0]["version"] == __version__ + assert entries[0]["version"] + assert entries[0]["date"] assert entries[0]["changes"] diff --git a/tests/test_config.py b/tests/test_config.py index 07df4c0..f18c591 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=("nickname", "email", "phone"), 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_contributions.py b/tests/test_contributions.py index 0905700..992cba1 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -3,8 +3,10 @@ from decimal import Decimal import pytest from ccma.domain.contributions import ( + allocated_total, claim_balance, claim_items, + claim_settled_total, claim_status, claim_total, payment_allocated_total, @@ -102,6 +104,25 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: ) +def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None: + claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} + data = ContributionData( + claims=[claim], + credits=[{"credit_id": "credit-1", "amount": "25.00"}], + allocations=[ + { + "allocation_id": "allocation-1", + "claim_id": "claim-1", + "credit_id": "credit-1", + "amount": "25.00", + } + ], + ) + + assert allocated_total(data, "claim-1") == Decimal("-25.00") + assert claim_settled_total(data, claim) == Decimal("25.00") + + def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path) diff --git a/tests/test_documents.py b/tests/test_documents.py index 8677c72..9f15944 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -58,6 +58,28 @@ def test_document_and_claim_creation_time_placeholders() -> None: assert values["claim.created_at"] == "21.06.2026 14:35" +def test_credit_claim_paid_placeholder_is_positive() -> None: + member = Member("member-1", "CCMA-1", "Ada", "Lovelace") + claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} + data = ContributionData( + claims=[claim], + credits=[{"credit_id": "credit-1", "amount": "25.00"}], + allocations=[ + { + "allocation_id": "allocation-1", + "claim_id": "claim-1", + "credit_id": "credit-1", + "amount": "25.00", + } + ], + ) + + values, _repeats = _template_values(member, data=data, claim=claim) + + assert values["claim.paid"] == "25.00 EUR" + assert values["claim.balance"] == "0.00 EUR" + + def test_claim_item_loop_clones_formatted_table_row() -> None: source = b""" 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..0aa46ed 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,7 +1,10 @@ import json +from decimal import Decimal import pytest +from ccma.domain.contributions import claim_balance +from ccma.services.housekeeper import Housekeeper from ccma.storage.repository import ( MemberRepository, RepositoryError, @@ -18,6 +21,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,7 +36,9 @@ 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 + assert raw["content_hash"] def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None: @@ -41,12 +47,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 +145,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" @@ -234,3 +242,187 @@ def test_organization_sender_data_is_stored_centrally(tmp_path) -> None: organization = repository.get_configuration()["organization"] assert organization["street"] == "Testweg 1" assert organization["iban"] == "DE89370400440532013000" + + +def test_repository_creates_asset_record_and_events(tmp_path) -> None: + repository = MemberRepository(tmp_path / "store") + repository.initialize() + + asset = repository.create_asset( + label="Clubraumschlüssel A12", + category="key", + inventory_number="KEY-A12", + deposit_amount_default="25", + ) + + asset_dir = repository.assets_root / asset.asset_id + assert (asset_dir / "asset.json").is_file() + assert (asset_dir / "events.jsonl").is_file() + assert (asset_dir / "files").is_dir() + loaded = repository.get_asset(asset.asset_id) + assert loaded.label == "Clubraumschlüssel A12" + assert loaded.deposit_amount_default == "25.00" + + +def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + other = repository.create_member(first_name="Grace", last_name="Hopper") + asset = repository.create_asset(label="Transponder 01") + + repository.assign_asset(asset.asset_id, member.member_id) + assigned = repository.get_asset(asset.asset_id) + assert assigned.status == "issued" + assert assigned.current_holder_member_id == member.member_id + assert [item.asset_id for item in repository.list_member_assets(member.member_id)] == [asset.asset_id] + with pytest.raises(RepositoryError, match="bereits einem Mitglied zugeordnet"): + repository.assign_asset(asset.asset_id, other.member_id) + + repository.return_asset(asset.asset_id) + returned = repository.get_asset(asset.asset_id) + assert returned.status == "available" + assert returned.current_holder_member_id == "" + assert repository.list_member_assets(member.member_id) == [] + + +def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Key", last_name="Holder", member_number="0042") + asset = repository.create_asset(label="Clubraumschlüssel") + + repository.assign_asset(asset.asset_id, member.member_id) + repository.return_asset(asset.asset_id) + + asset_events = [event.event_type for event in repository.get_asset_events(asset.asset_id)] + member_events = [event.event_type for event in repository.get_events(member.member_id)] + assert asset_events == ["asset_created", "asset_issued", "asset_returned"] + assert "asset_assigned" in member_events + assert "asset_returned" in member_events + + +def test_asset_deposit_cannot_change_while_issued(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + asset = repository.create_asset(label="Clubraumschlüssel", deposit_amount_default="25") + repository.assign_asset(asset.asset_id, member.member_id) + + issued = repository.get_asset(asset.asset_id) + issued.deposit_amount_default = "35" + with pytest.raises(RepositoryError, match="Kaution kann nur geändert werden"): + repository.save_asset(issued) + + +def test_asset_deposit_can_change_when_not_issued(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + asset = repository.create_asset(label="Clubraumschlüssel", deposit_amount_default="25") + + asset.deposit_amount_default = "35" + repository.save_asset(asset) + + updated = repository.get_asset(asset.asset_id) + assert updated.deposit_amount_default == "35.00" + + +def test_manual_asset_claim_is_linked_to_member_and_asset(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + asset = repository.create_asset(label="Clubraumschlüssel") + repository.assign_asset(asset.asset_id, member.member_id) + + result = repository.create_manual_claim( + member.member_id, + title="Kaution Clubraumschlüssel", + amount="25.00", + due_date="2026-06-26", + description="Kaution für Schlüssel", + claim_type="asset_deposit", + references={"asset_id": asset.asset_id}, + ) + + claim = result["claim"] + loaded_claim = repository.get_contributions(member.member_id).claims[0] + assert claim["claim_id"] == loaded_claim["claim_id"] + assert loaded_claim["origin"]["asset_id"] == asset.asset_id + assert repository.get_asset_events(asset.asset_id)[-1].event_type == "asset_claim_created" + + +def test_negative_claim_can_be_settled_with_credit(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + asset = repository.create_asset(label="Clubraumschlüssel") + repository.assign_asset(asset.asset_id, member.member_id) + claim = repository.create_manual_claim( + member.member_id, + title="Kautionsrückzahlung", + amount="-25.00", + due_date="2026-06-26", + claim_type="asset_refund", + references={"asset_id": asset.asset_id}, + )["claim"] + + repository.record_credit( + member.member_id, + str(claim["claim_id"]), + credit_date="2026-06-26", + amount="25.00", + allocation_amount="25.00", + reference="Bar ausgezahlt", + ) + + data, loaded_claim = repository.get_claim(member.member_id, str(claim["claim_id"])) + assert claim_balance(data, loaded_claim) == Decimal("0.00") + assert data.credits[0]["amount"] == "25.00" + + +def test_member_hash_warning_does_not_block_reading(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + path = repository.members_root / member.member_id / "member.json" + raw = json.loads(path.read_text(encoding="utf-8")) + raw["person"]["first_name"] = "Eve" + path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") + + loaded = repository.get_member(member.member_id) + warnings = repository.member_hash_warnings(member.member_id) + + assert loaded.first_name == "Eve" + assert warnings + assert "Hash fehlt oder stimmt nicht" in warnings[0] + + +def test_refresh_member_record_hashes_clears_hash_warning(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + path = repository.members_root / member.member_id / "member.json" + raw = json.loads(path.read_text(encoding="utf-8")) + raw["person"]["first_name"] = "Eve" + path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") + + assert repository.member_hash_warnings(member.member_id) + repository.refresh_member_record_hashes(member.member_id) + assert repository.member_hash_warnings(member.member_id) == [] + + +def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Ada", last_name="Lovelace") + path = repository.members_root / member.member_id / "member.json" + raw = json.loads(path.read_text(encoding="utf-8")) + raw["person"]["last_name"] = "Example" + path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8") + + findings = Housekeeper(repository).run() + + assert any( + finding.code == "json_hash_mismatch" and finding.member_id == member.member_id + for finding in findings + ) diff --git a/tests/test_rules.py b/tests/test_rules.py index 9283f26..b54c1c6 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"] == "112.50" + + +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() @@ -102,7 +180,7 @@ def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: repository.save_member(member) housekeeper.run(today=date(2026, 6, 21)) state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8")) - task = next(item for item in state["items"] if item["key"].endswith(":missing")) + task = next(item for item in state["items"] if item["key"].endswith(":missing:birth_date")) assert task["status"] == "resolved" assert task["first_seen_run"] == "2026-06-21:000001" diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index 38c1e42..c3a8f21 100644 --- a/tests/test_ui_imports.py +++ b/tests/test_ui_imports.py @@ -1,5 +1,6 @@ def test_ui_modules_import_without_creating_root_window() -> None: import ccma.app # noqa: F401 + import ccma.ui.asset_tab # noqa: F401 import ccma.ui.claim_tab # noqa: F401 import ccma.ui.main_window # noqa: F401 import ccma.ui.member_tab # noqa: F401 @@ -68,16 +69,16 @@ def test_housekeeper_details_are_multiline() -> None: finding = HousekeeperFinding( severity="error", - member_id="member-1", code="invalid_member_record", title="Mitgliederakte beschädigt", detail="Die JSON-Datei ist leer und wird nicht automatisch überschrieben.", + member_id="member-1", due_date=date(2026, 7, 31), ) rendered = _finding_details(finding) assert rendered.splitlines()[0] == "ERROR · invalid_member_record" - assert "Mitgliederakte beschädigt\nFällig:" in rendered + assert "Mitgliederakte beschädigt\nMitglied: member-1\nFällig:" in rendered assert rendered.endswith("nicht automatisch überschrieben.") @@ -96,3 +97,87 @@ 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_asset_table_filter_and_sort_use_status_and_holder_label() -> None: + from ccma.domain.models import Asset + from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter + + assets = [ + Asset("1", "Clubraumschlüssel", status="issued", current_holder_member_id="member-1"), + Asset("2", "Beamer", status="available"), + ] + + assert _selected_asset_filter("Alle") == "all" + assert _selected_asset_filter("AUSGEGEBEN") == "issued" + assert [asset.asset_id for asset in _filter_assets(assets, "available")] == ["2"] + assert _asset_table_value(assets[0], "holder", "0001 · Ada") == "0001 · Ada" + + +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") + + +def test_negative_claims_are_labeled_as_credit() -> None: + from ccma.domain.contributions import claim_status + from ccma.domain.models import ContributionData + + data = ContributionData() + claim = {"claim_id": "claim-1", "title": "Rueckzahlung", "amount": "-25.00"} + assert claim_status(data, claim) == "credit" + + +def test_housekeeper_details_include_asset_target() -> None: + from ccma.domain.models import HousekeeperFinding + from ccma.ui.work_tabs import _finding_details + + finding = HousekeeperFinding( + severity="warning", + code="json_hash_mismatch", + title="Assetakte extern geändert", + detail="asset.json: Hash fehlt oder stimmt nicht.", + asset_id="asset-1", + target_type="asset", + ) + + rendered = _finding_details(finding) + assert "Asset: asset-1" in rendered diff --git a/tests/test_version.py b/tests/test_version.py index f7f28e7..cc5024d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -5,4 +5,4 @@ from ccma import __version__ def test_ui_version_matches_version_file() -> None: expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() - assert __version__ == expected == "0.0.1-dev0" + assert __version__ == expected == "0.0.1-dev1"