Files
CCMA/src/ccma/ui/asset_tab.py
T

535 lines
22 KiB
Python

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("<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(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("<Button-1>", 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("<Button-1>", 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("<Double-1>", lambda _event: self._open_selected_asset_claim())
self.asset_claims.bind("<Return>", 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("<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.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()