From d1dab793a68d3dc3d1171ff8a7e208b442785fcb Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 26 Jun 2026 23:03:06 +0200 Subject: [PATCH] Add asset records, claims, and credit workflows --- src/ccma/domain/contributions.py | 23 +- src/ccma/domain/models.py | 59 ++++ src/ccma/storage/repository.py | 443 ++++++++++++++++++++++++++++++- src/ccma/ui/asset_tab.py | 378 ++++++++++++++++++++++++++ src/ccma/ui/claim_tab.py | 157 +++++++++++ src/ccma/ui/dialogs.py | 424 ++++++++++++++++++++++++++++- src/ccma/ui/main_window.py | 144 +++++++++- src/ccma/ui/member_tab.py | 72 ++++- src/ccma/ui/work_tabs.py | 207 ++++++++++++++- tests/test_repository.py | 138 ++++++++++ tests/test_ui_imports.py | 25 ++ 11 files changed, 2060 insertions(+), 10 deletions(-) create mode 100644 src/ccma/ui/asset_tab.py diff --git a/src/ccma/domain/contributions.py b/src/ccma/domain/contributions.py index 140df2a..c731f2e 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 ), @@ -79,6 +87,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 +108,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 6449433..04682f6 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -20,6 +20,13 @@ 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", @@ -167,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 @@ -209,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 @@ -218,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, } @@ -228,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 []), ) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 95f1d2f..4c6bfdf 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -13,13 +13,14 @@ 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.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, ContributionData, Event, Member from ccma.storage.atomic import read_json, write_json_atomic @@ -114,9 +115,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) @@ -177,6 +180,30 @@ 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) + 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: 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]: @@ -313,6 +340,299 @@ 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(): @@ -321,7 +641,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]): @@ -476,6 +796,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, @@ -793,6 +1199,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") @@ -849,11 +1258,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) @@ -936,6 +1357,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..159c936 --- /dev/null +++ b/src/ccma/ui/asset_tab.py @@ -0,0 +1,378 @@ +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, Asset, Event +from ccma.storage.repository import MemberRepository, RepositoryError +from ccma.ui.dialogs import AssetClaimDialog + + +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(1, weight=1) + header = ttk.Frame(self) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + self.title_var = tk.StringVar() + self.subtitle_var = tk.StringVar() + ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(row=0, column=0, sticky="w") + ttk.Label(header, textvariable=self.subtitle_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, sticky="e") + + self.pane = ttk.Panedwindow(self, orient="horizontal") + self.pane.grid(row=1, 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(0, weight=1) + notebook = ttk.Notebook(parent) + notebook.grid(row=0, column=0, sticky="nsew") + data_tab = ttk.Frame(notebook, padding=16) + finance_tab = ttk.Frame(notebook, padding=16) + notebook.add(data_tab, text="Stammdaten") + notebook.add(finance_tab, text="Forderungen") + + fields = [ + ("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 == "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)) + ttk.Label(data_tab, textvariable=self.holder_var, style="TimelineHeader.TLabel").grid( + row=holder_row + 1, column=1, sticky="w", pady=5 + ) + 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) + actions = ttk.Frame(data_tab) + actions.grid(row=holder_row + 3, column=1, sticky="e", pady=(16, 0)) + self.open_member_button = ttk.Button(actions, text="Mitglied öffnen", command=self._open_holder_member) + self.open_member_button.pack(side="left", padx=(0, 8)) + self.issue_button = ttk.Button(actions, 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(actions, text="Zurücknehmen", command=lambda: self.on_return_asset(self.asset_id)) + self.return_button.pack(side="left", padx=(0, 8)) + ttk.Button(actions, 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 _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.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.open_member_button.configure(state="normal" if issued else "disabled") + 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: + 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) + except RepositoryError as exc: + messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self) + return + self.refresh() + self.on_changed() + + 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..2117755 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -14,6 +14,7 @@ from ccma.domain.contributions import ( claim_items, claim_status, claim_total, + credit_allocated_total, decimal_value, money_text, payment_allocated_total, @@ -125,6 +126,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 +140,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" @@ -227,6 +234,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 +255,43 @@ 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", "")) + ] + credit_group = self.ledger.insert( + "", + "end", + iid="group:credits", + text=f"Gutschriften ({len(credit_allocations)})", + values=("", "", "", "", f"{money_text(sum((decimal_value(item.get('amount', '0')) for item in credit_allocations), Decimal('0')))} 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 +355,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 +574,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 +665,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 531fd34..f49b936 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -2,11 +2,28 @@ import tkinter as tk from collections.abc import Callable from tkinter import messagebox, ttk +from datetime import date + 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,7 +31,6 @@ 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 = { @@ -24,8 +40,12 @@ class NewMemberDialog(tk.Toplevel): 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) @@ -90,3 +110,403 @@ 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"])) diff --git a/src/ccma/ui/main_window.py b/src/ccma/ui/main_window.py index a3cfd5a..17f4553 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): @@ -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)})") @@ -422,3 +550,13 @@ class MainWindow(ttk.Frame): "Der Store enthält ungültige Akten:\n\n" + "\n".join(self.validation_errors[:12]), parent=self, ) + + 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 6bcca1c..c277810 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -8,7 +8,7 @@ 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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS +from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import Event from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.document_dialog import DocumentTemplateDialog @@ -46,6 +46,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 @@ -53,6 +56,9 @@ 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.notes_text: tk.Text | None = None @@ -108,11 +114,13 @@ class MemberTab(ttk.Frame): address_tab = ttk.Frame(notebook, padding=16) banking_tab = ttk.Frame(notebook, padding=16) 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 = [ @@ -228,6 +236,40 @@ class MemberTab(ttk.Frame): 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) @@ -311,6 +353,7 @@ class MemberTab(ttk.Frame): self.notes_text.insert("1.0", self.member.notes) self._refresh_events() self._refresh_contributions() + self._refresh_assets() self._refresh_documents() def _refresh_events(self) -> None: @@ -399,6 +442,23 @@ 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: for key, variable in self.variables.items(): raw_value = variable.get() @@ -434,6 +494,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/work_tabs.py b/src/ccma/ui/work_tabs.py index 06cb1d6..af7c845 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -4,7 +4,7 @@ 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 @@ -19,6 +19,14 @@ MEMBER_TABLE_COLUMNS = ( ) 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: @@ -59,6 +67,26 @@ def _selected_status_filter(label: str) -> str: return storage_key(MEMBERSHIP_STATUS_LABELS, label) +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): def __init__( self, @@ -281,6 +309,177 @@ class MembersTab(ttk.Frame): 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 = ttk.Frame(self, padding=(12, 10)) + filters.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + ttk.Label(filters, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) + self.status_filter = ttk.Combobox( + filters, + 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, @@ -415,3 +614,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_repository.py b/tests/test_repository.py index 1b92ca6..fbbbe87 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,7 +1,9 @@ import json +from decimal import Decimal import pytest +from ccma.domain.contributions import claim_balance from ccma.storage.repository import ( MemberRepository, RepositoryError, @@ -238,3 +240,139 @@ 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" diff --git a/tests/test_ui_imports.py b/tests/test_ui_imports.py index 42cc141..6843203 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 @@ -130,6 +131,21 @@ def test_member_table_sort_uses_display_values() -> None: 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 @@ -139,3 +155,12 @@ def test_claim_table_sort_uses_due_date_by_raw_value() -> None: 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"