from __future__ import annotations import tkinter as tk from collections.abc import Callable from datetime import datetime from tkinter import messagebox, ttk from ccma.domain.models import ASSET_STATUS_LABELS, Event from ccma.storage.repository import MemberRepository, RepositoryError from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.scrolling import ScrollableFrame class AssetTab(ttk.Frame): def __init__( self, master: tk.Misc, repository: MemberRepository, asset_id: str, on_close: Callable[[], None], on_changed: Callable[[], None], on_open_member: Callable[[str], None], on_issue_asset: Callable[[str], None], on_return_asset: Callable[[str], None], on_open_claim: Callable[[str, str], None], ): super().__init__(master, padding=12) self.repository = repository self.asset_id = asset_id self.on_close = on_close self.on_changed = on_changed self.on_open_member = on_open_member self.on_issue_asset = on_issue_asset self.on_return_asset = on_return_asset self.on_open_claim = on_open_claim self.asset = repository.get_asset(asset_id) self.variables: dict[str, tk.StringVar] = {} self.notes_text: tk.Text | None = None self._build_ui() self.refresh() def _build_ui(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(2, weight=1) header = ttk.Frame(self) header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.columnconfigure(0, weight=1) header.columnconfigure(1, weight=0) self.title_var = tk.StringVar() self.subtitle_var = tk.StringVar() self.id_var = tk.StringVar() title_column = ttk.Frame(header) title_column.grid(row=0, column=0, sticky="ew") title_column.columnconfigure(0, weight=1) ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid( row=0, column=0, sticky="w" ) ttk.Label(title_column, textvariable=self.subtitle_var, style="Mono.TLabel").grid( row=1, column=0, sticky="w" ) ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid( row=2, column=0, sticky="w", pady=(3, 0) ) ttk.Button(header, text="Tab schließen", command=self.on_close).grid( row=0, column=1, sticky="ne", padx=(12, 0) ) self.messages = MessageBannerList(self) self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10)) self.messages.grid_remove() self.pane = ttk.Panedwindow(self, orient="horizontal") self.pane.grid(row=2, column=0, sticky="nsew") self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0)) self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0)) self.pane.add(self.details_pane, weight=2) self.pane.add(self.timeline_pane, weight=3) self._build_details(self.details_pane) self._build_timeline(self.timeline_pane) self._pane_position_initialized = False self.pane.bind("", self._set_initial_pane_position, add="+") def _set_initial_pane_position(self, event: tk.Event | None = None) -> None: if self._pane_position_initialized: return try: width = int(getattr(event, "width", 0)) or self.pane.winfo_width() if width > 1: self.pane.sashpos(0, max(360, int(width * 0.4))) self._pane_position_initialized = True except tk.TclError: return def _build_details(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(1, weight=1) ttk.Label(parent, text="// ASSET", style="TimelineHeader.TLabel").grid( row=0, column=0, sticky="w", pady=(0, 8) ) notebook = ttk.Notebook(parent) notebook.grid(row=1, column=0, sticky="nsew") data_tab = self._create_form_tab(notebook, "Stammdaten") actions = ttk.Frame(parent) actions.grid(row=2, column=0, sticky="ew", pady=(10, 0)) actions.columnconfigure(0, weight=1) action_buttons = ttk.Frame(actions) action_buttons.grid(row=0, column=0, sticky="e") finance_tab = ttk.Frame(notebook, padding=16) notebook.add(finance_tab, text="Forderungen") fields = [ ("UUID / Ordner-ID", "asset_id"), ("Bezeichnung", "label"), ("Kategorie", "category"), ("Inventarnummer", "inventory_number"), ("Seriennummer", "serial_number"), ("Kaution (EUR)", "deposit_amount_default"), ("Standort", "location"), ("Übergabedatum", "handed_over_at"), ("Zustand bei Übergabe", "condition"), ("Wert / Kaufpreis", "estimated_value"), ] for row, (label, key) in enumerate(fields): self.variables[key] = tk.StringVar() ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12)) entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42) entry.grid(row=row, column=1, sticky="ew", pady=5) if key == "asset_id": entry.configure(state="readonly") if key == "deposit_amount_default": self.deposit_entry = entry self.variables["status"] = tk.StringVar() holder_row = len(fields) ttk.Label(data_tab, text="Status").grid(row=holder_row, column=0, sticky="w", pady=5, padx=(0, 12)) self.status_box = ttk.Combobox( data_tab, textvariable=self.variables["status"], values=["VERFUEGBAR", "AUSGEGEBEN", "VERLOREN", "AUSGEMUSTERT"], state="readonly", width=39, ) self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5) self.holder_var = tk.StringVar() ttk.Label(data_tab, text="Aktueller Halter").grid( row=holder_row + 1, column=0, sticky="w", pady=5, padx=(0, 12), ) self.holder_label = ttk.Label( data_tab, textvariable=self.holder_var, style="TimelineHeader.TLabel", ) self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5) self.holder_label.bind("", lambda _event: self._open_holder_member(), add="+") self.owner_var = tk.StringVar() ttk.Label(data_tab, text="Eigentümer").grid( row=holder_row + 2, column=0, sticky="w", pady=5, padx=(0, 12) ) self.owner_label = ttk.Label(data_tab, textvariable=self.owner_var, style="TimelineHeader.TLabel") self.owner_label.grid(row=holder_row + 2, column=1, sticky="w", pady=5) self.owner_label.bind("", lambda _event: self._open_owner_member(), add="+") ttk.Label(data_tab, text="Interne Notiz").grid( row=holder_row + 3, 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 + 3, column=1, sticky="ew", pady=5) data_tab.columnconfigure(1, weight=1) self.issue_button = ttk.Button( action_buttons, text="Ausgeben", command=lambda: self.on_issue_asset(self.asset_id), ) self.issue_button.pack(side="left", padx=(0, 8)) self.return_button = ttk.Button( action_buttons, text="Zurücknehmen", command=lambda: self.on_return_asset(self.asset_id), ) self.return_button.pack(side="left", padx=(0, 8)) ttk.Button( action_buttons, text="Speichern", style="Accent.TButton", command=self._save, ).pack(side="left") finance_tab.columnconfigure(0, weight=1) self.finance_summary_var = tk.StringVar() ttk.Label(finance_tab, textvariable=self.finance_summary_var, style="Mono.TLabel").grid( row=0, column=0, sticky="w", pady=(0, 10) ) finance_actions = ttk.Frame(finance_tab) finance_actions.grid(row=1, column=0, sticky="ew") ttk.Button(finance_actions, text="Kautionsforderung", command=self._create_deposit_claim).pack( side="left", padx=(0, 8) ) ttk.Button(finance_actions, text="Kautionsrückzahlung", command=self._create_refund_claim).pack( side="left", padx=(0, 8) ) ttk.Button(finance_actions, text="Verlustforderung", command=self._create_loss_claim).pack( side="left", padx=(0, 8) ) ttk.Button( finance_actions, text="Reparaturforderung", command=self._create_repair_claim, ).pack(side="left") self.asset_claims = ttk.Treeview( finance_tab, columns=("title", "due", "amount", "member"), show="headings", ) for key, title, width in ( ("title", "Forderung", 240), ("due", "Fällig", 110), ("amount", "Betrag", 90), ("member", "Mitglied", 220), ): self.asset_claims.heading(key, text=title) self.asset_claims.column(key, width=width, anchor="w") self.asset_claims.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) finance_tab.rowconfigure(2, weight=1) self.asset_claims.bind("", lambda _event: self._open_selected_asset_claim()) self.asset_claims.bind("", lambda _event: self._open_selected_asset_claim()) def _create_form_tab(self, notebook: ttk.Notebook, title: str) -> ttk.Frame: tab = ttk.Frame(notebook) tab.columnconfigure(0, weight=1) tab.rowconfigure(0, weight=1) scroller = ScrollableFrame(tab, padding=16) scroller.grid(row=0, column=0, sticky="nsew") notebook.add(tab, text=title) return scroller.content def _build_timeline(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(1, weight=1) ttk.Label(parent, text="// CHRONIK", style="TimelineHeader.TLabel").grid( row=0, column=0, sticky="w", pady=(0, 8) ) self.timeline = ttk.Treeview( parent, columns=("time", "summary"), show="headings", style="Timeline.Treeview" ) self.timeline.heading("time", text="Zeit") self.timeline.heading("summary", text="Ereignis") self.timeline.column("time", width=135, stretch=False) self.timeline.column("summary", width=320, stretch=True) self.timeline.grid(row=1, column=0, sticky="nsew") compose = ttk.Frame(parent) compose.grid(row=2, column=0, sticky="ew", pady=(10, 0)) compose.columnconfigure(0, weight=1) self.comment_var = tk.StringVar() comment = ttk.Entry(compose, textvariable=self.comment_var) comment.grid(row=0, column=0, sticky="ew", padx=(0, 6)) comment.bind("", lambda _event: self._add_comment()) ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1) def refresh(self) -> None: self.asset = self.repository.get_asset(self.asset_id) self.title_var.set(self.asset.label or self.asset.asset_id) holder = self._holder_label() status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()) self.subtitle_var.set(f"{self.asset.inventory_number or '—'} · {status} · {holder}") self.id_var.set(f"UUID: {self.asset.asset_id}") warnings = self.repository.asset_hash_warnings(self.asset_id) self.messages.set_messages( [ TabMessage( "warning", "WARNUNG: " + " | ".join(warnings), MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner), ) ] if warnings else [] ) self.variables["asset_id"].set(self.asset.asset_id) self.variables["label"].set(self.asset.label) self.variables["category"].set(self.asset.category) self.variables["inventory_number"].set(self.asset.inventory_number) self.variables["serial_number"].set(self.asset.serial_number) self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default) self.variables["location"].set(self.asset.location) self.variables["handed_over_at"].set(self.asset.handed_over_at) self.variables["condition"].set(self.asset.condition) self.variables["estimated_value"].set(self.asset.estimated_value) 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) self.owner_var.set(self._owner_label()) issued = bool(self.asset.current_holder_member_id) self.holder_label.configure(cursor="hand2" if issued else "") self.owner_label.configure(cursor="hand2" if self.asset.owner_type == "member" else "") self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled") self.return_button.configure(state="normal" if issued else "disabled") self.status_box.configure(state="disabled" if issued else "readonly") deposit_state = "readonly" if issued else "normal" self.deposit_entry.configure(state=deposit_state) self._refresh_events() self._refresh_finance() def _holder_label(self) -> str: if not self.asset.current_holder_member_id: return "—" try: member = self.repository.get_member(self.asset.current_holder_member_id) except RepositoryError: return self.asset.current_holder_member_id return f"{member.member_number or member.member_id} · {member.display_name}" def _owner_label(self) -> str: if self.asset.owner_type == "club": return "Verein" if self.asset.owner_type == "external": return self.asset.owner_name or "Extern" try: member = self.repository.get_member(self.asset.owner_member_id) except RepositoryError: return self.asset.owner_member_id or "—" return f"{member.member_number or member.member_id} · {member.display_name}" def _open_owner_member(self) -> None: if self.asset.owner_type == "member" and self.asset.owner_member_id: self.on_open_member(self.asset.owner_member_id) def _refresh_events(self) -> None: self.timeline.delete(*self.timeline.get_children()) try: events = self.repository.get_asset_events(self.asset_id) except RepositoryError as exc: messagebox.showerror("Chronik beschädigt", str(exc), parent=self) return for event in reversed(events): self.timeline.insert("", "end", values=(_format_timestamp(event), _event_label(event))) def _refresh_finance(self) -> None: self.asset_claims.delete(*self.asset_claims.get_children()) linked_claims: list[tuple[str, str, dict]] = [] for member in self.repository.list_members(): try: data = self.repository.get_contributions(member.member_id) except RepositoryError: continue for claim in data.claims: origin = claim.get("origin") or {} if isinstance(origin, dict) and str(origin.get("asset_id", "")) == self.asset_id: linked_claims.append((member.member_id, member.display_name, claim)) for member_id, member_name, claim in linked_claims: claim_id = str(claim.get("claim_id", "")) self.asset_claims.insert( "", "end", iid=f"{member_id}:{claim_id}", values=( str(claim.get("title", "")), str(claim.get("due_date", "")), str(claim.get("amount", "")), member_name, ), ) self.finance_summary_var.set( "Forderungen für dieses Asset. Negative Betraege dokumentieren Gutschriften/Rueckzahlungen." ) def _save(self) -> None: warnings = self.repository.asset_hash_warnings(self.asset_id) if warnings: self._confirm_integrity_and_then(self._save_confirmed) return self._save_confirmed() def _save_confirmed(self) -> None: self.asset.label = self.variables["label"].get().strip() self.asset.category = self.variables["category"].get().strip() self.asset.inventory_number = self.variables["inventory_number"].get().strip() self.asset.serial_number = self.variables["serial_number"].get().strip() self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else "" self.asset.location = self.variables["location"].get().strip() self.asset.handed_over_at = self.variables["handed_over_at"].get().strip() self.asset.condition = self.variables["condition"].get().strip() self.asset.estimated_value = self.variables["estimated_value"].get().strip() if not self.asset.current_holder_member_id: self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip() self.asset.status = _asset_status_key(self.variables["status"].get()) try: self.repository.save_asset(self.asset) self.repository.refresh_asset_record_hashes(self.asset_id) except RepositoryError as exc: messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self) return self.refresh() self.on_changed() def _confirm_integrity_banner(self) -> None: self._confirm_integrity_and_then(self._refresh_hashes_only) def _refresh_hashes_only(self) -> None: self.repository.refresh_asset_record_hashes(self.asset_id) self.refresh() self.on_changed() def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None: warnings = self.repository.asset_hash_warnings(self.asset_id) if not warnings: callback() return IntegrityWarningDialog( self, title="Externe Änderungen bestätigen", warnings=warnings, on_confirm=callback, ) def _add_comment(self) -> None: text = self.comment_var.get().strip() if not text: return self.repository.append_asset_event( self.asset_id, event_type="board_comment", summary=text, actor_type="user", actor_name="Vorstand", ) self.comment_var.set("") self._refresh_events() def _open_holder_member(self) -> None: if self.asset.current_holder_member_id: self.on_open_member(self.asset.current_holder_member_id) def _create_deposit_claim(self) -> None: self._open_claim_dialog( preset_title=f"Kaution: {self.asset.label}", preset_amount=self.asset.deposit_amount_default or "0.00", preset_description=f"Kaution für Asset {self.asset.label}", claim_type="asset_deposit", ) def _create_refund_claim(self) -> None: amount = self.asset.deposit_amount_default or "0.00" if not str(amount).startswith("-"): amount = f"-{amount}" self._open_claim_dialog( preset_title=f"Kautionsrückzahlung: {self.asset.label}", preset_amount=amount, preset_description=f"Gutschrift / Rückzahlung der Kaution für Asset {self.asset.label}", claim_type="asset_refund", ) def _create_loss_claim(self) -> None: self._open_claim_dialog( preset_title=f"Verlust: {self.asset.label}", preset_amount=self.asset.deposit_amount_default or "0.00", preset_description=f"Forderung wegen Verlust von Asset {self.asset.label}", claim_type="asset_loss", ) def _create_repair_claim(self) -> None: self._open_claim_dialog( preset_title=f"Reparatur: {self.asset.label}", preset_amount="0.00", preset_description=f"Reparaturkosten für Asset {self.asset.label}", claim_type="asset_repair", ) def _open_claim_dialog( self, *, preset_title: str, preset_amount: str, preset_description: str, claim_type: str, ) -> None: member_id = self.asset.current_holder_member_id if not member_id: messagebox.showerror( "Forderung nicht möglich", "Dieses Asset ist aktuell keinem Mitglied zugeordnet.", parent=self, ) return AssetClaimDialog( self, self.repository, self.asset_id, member_id, preset_title=preset_title, preset_amount=preset_amount, preset_description=preset_description, claim_type=claim_type, on_created=lambda claim_id: self._claim_created(member_id, claim_id), ) def _claim_created(self, member_id: str, claim_id: str) -> None: self.refresh() self.on_changed() self.on_open_claim(member_id, claim_id) def _open_selected_asset_claim(self) -> None: selected = self.asset_claims.selection() if not selected: return member_id, claim_id = selected[0].split(":", 1) self.on_open_claim(member_id, claim_id) def _format_timestamp(event: Event) -> str: try: return datetime.fromisoformat(event.timestamp).strftime("%d.%m.%Y %H:%M") except ValueError: return event.timestamp[:16] def _event_label(event: Event) -> str: if event.actor_type == "system": return f"[AUTO] {event.summary}" return event.summary def _asset_status_key(label: str) -> str: for key, value in ASSET_STATUS_LABELS.items(): if value == label: return key return label.casefold().strip()