mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
Add asset records, claims, and credit workflows
This commit is contained in:
+422
-2
@@ -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"]))
|
||||
|
||||
Reference in New Issue
Block a user