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
+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"]))