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 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) self.repository = repository self.on_created = on_created self.title("Neue Mitgliederakte") self.transient(master.winfo_toplevel()) self.resizable(False, False) self.number_policy = repository.get_member_number_policy() self.variables = { name: tk.StringVar() for name in ("first_name", "last_name", "nickname", "email", "phone", "birth_date", "member_number") } self._build_ui() self.bind("", lambda _event: self.destroy()) self.bind("", 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) fields = [ ("Vorname *", "first_name"), ("Nachname *", "last_name"), ("Nickname", "nickname"), ("E-Mail-Adresse", "email"), ("Telefonnummer", "phone"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"), ] if self.number_policy["mode"] == "manual": fields.append(("Mitgliedsnummer *", "member_number")) 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)) if key == "birth_date": birth_row = ttk.Frame(frame) birth_row.grid(row=row, column=1, sticky="ew", pady=5) birth_row.columnconfigure(0, weight=1) entry = ttk.Entry(birth_row, textvariable=self.variables[key], width=24) entry.grid(row=0, column=0, sticky="ew") self.birth_age_var = tk.StringVar(value="Alter: —") ttk.Label(birth_row, textvariable=self.birth_age_var, style="Mono.TLabel").grid( row=0, column=1, sticky="w", padx=(10, 0) ) self.variables[key].trace_add( "write", lambda *_args: self.birth_age_var.set(age_label(self.variables["birth_date"].get())), ) else: entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) entry.grid(row=row, column=1, sticky="ew", pady=5) self.entries[key] = entry button_row = len(fields) if self.number_policy["mode"] == "automatic": preview = self.repository.preview_member_number(self.number_policy["pattern"]) ttk.Label(frame, text="Mitgliedsnummer").grid( row=button_row, column=0, sticky="w", pady=5, padx=(0, 12) ) ttk.Label(frame, text=f"Automatisch: {preview}", style="TimelineHeader.TLabel").grid( row=button_row, column=1, sticky="w", pady=5 ) button_row += 1 buttons = ttk.Frame(frame) buttons.grid(row=button_row, 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="Akte anlegen", style="Accent.TButton", command=self._create).pack( side="left" ) def _focus_first(self) -> None: self.entries["first_name"].focus_set() def _create(self) -> None: try: member = self.repository.create_member( **{key: variable.get() for key, variable in self.variables.items()} ) except RepositoryError as exc: messagebox.showerror("Akte konnte nicht angelegt werden", str(exc), parent=self) 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("", lambda _event: self.destroy()) self.bind("", 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("", lambda _event: self.destroy()) self.bind("", 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("", lambda _event: self.destroy()) self.bind("", 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("", lambda _event: self._assign()) self.member_tree.bind("", lambda _event: self._assign()) self.member_tree.bind("<>", 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("", lambda _event: self.destroy()) self.bind("", 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"])) class IntegrityWarningDialog(tk.Toplevel): def __init__( self, master: tk.Misc, *, title: str, warnings: list[str], on_confirm: Callable[[], None], ): super().__init__(master) self.on_confirm = on_confirm self.title(title) self.transient(master.winfo_toplevel()) self.resizable(False, False) self.warnings = warnings self._build_ui() self.bind("", lambda _event: self.destroy()) self.after_idle(self._activate_modal) def _build_ui(self) -> None: frame = ttk.Frame(self, padding=18) frame.pack(fill="both", expand=True) message = ( "ACHTUNG: Die zugehörigen JSON-Dateien wurden vermutlich extern geändert.\n\n" "Haben Sie alle Daten geprüft und soll der Hash jetzt aktualisiert werden?" ) ttk.Label(frame, text=message, justify="left").grid(row=0, column=0, sticky="w") ttk.Label(frame, text="\n".join(f"• {item}" for item in self.warnings), style="Warning.TLabel", justify="left").grid( row=1, column=0, sticky="w", pady=(12, 0) ) buttons = ttk.Frame(frame) buttons.grid(row=2, column=0, sticky="e", pady=(18, 0)) ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8)) ttk.Button(buttons, text="Ja, bestätigen", style="Accent.TButton", command=self._confirm).pack(side="left") def _activate_modal(self) -> None: _activate_modal_window(self) def _confirm(self) -> None: self.destroy() self.on_confirm()