Add asset records, claims, and credit workflows

This commit is contained in:
Marcel Peterkau
2026-06-26 23:03:06 +02:00
parent 30b6d253b2
commit d1dab793a6
11 changed files with 2060 additions and 10 deletions
+22 -1
View File
@@ -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:
+59
View File
@@ -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 []),
)
+441 -2
View File
@@ -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()
+378
View File
@@ -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("<Configure>", 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("<Double-1>", lambda _event: self._open_selected_asset_claim())
self.asset_claims.bind("<Return>", 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("<Return>", 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()
+157
View File
@@ -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("<<TreeviewSelect>>", 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("<<ComboboxSelected>>", 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)
+422 -2
View File
@@ -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("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", 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("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", 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("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", 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("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", 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("<Double-1>", lambda _event: self._assign())
self.member_tree.bind("<Return>", lambda _event: self._assign())
self.member_tree.bind("<<TreeviewSelect>>", 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("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", 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"]))
+141 -3
View File
@@ -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}"
+71 -1
View File
@@ -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("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", 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("<Double-1>", lambda _event: self._open_selected_asset())
self.assets_tree.bind("<Return>", 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:
+206 -1
View File
@@ -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("<<ComboboxSelected>>", 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("<Double-1>", self._open_selected)
self.tree.bind("<<TreeviewSelect>>", 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)
+138
View File
@@ -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"
+25
View File
@@ -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"