mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Add asset records, claims, and credit workflows
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user