Files
CCMA/src/ccma/ui/work_tabs.py
T
2026-06-27 10:46:54 +02:00

666 lines
25 KiB
Python

import tkinter as tk
from collections import Counter
from collections.abc import Callable
from tkinter import messagebox, ttk
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS,
Asset,
HousekeeperFinding,
Member,
)
from ccma.ui.labels import storage_key
from ccma.ui.sections import titled_frame
MEMBER_TABLE_COLUMNS = (
("number", "Nummer", 110),
("first_name", "Vorname", 160),
("last_name", "Nachname", 180),
("nickname", "Nickname", 160),
("email", "E-Mail-Adresse", 270),
("birth", "Geburtsdatum", 120),
("status", "Status", 170),
)
STATUS_FILTER_ALL = "Alle"
ASSET_FILTER_ALL = "Alle"
ASSET_TABLE_COLUMNS = (
("label", "Bezeichnung", 260),
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
("holder", "Mitglied", 240),
)
def _member_table_value(member: Member, column: str) -> str:
if column == "number":
return member.member_number
if column == "first_name":
return member.first_name
if column == "last_name":
return member.last_name
if column == "nickname":
return member.nickname
if column == "email":
return member.email
if column == "birth":
return member.birth_date
if column == "status":
return MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)
return ""
def _filter_members(members: list[Member], status_filter: str) -> list[Member]:
if status_filter == "all":
return list(members)
return [member for member in members if member.status == status_filter]
def _sort_members(members: list[Member], column: str, descending: bool) -> list[Member]:
return sorted(
members,
key=lambda member: _member_table_value(member, column).casefold(),
reverse=descending,
)
def _selected_status_filter(label: str) -> str:
if label == STATUS_FILTER_ALL:
return "all"
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
return titled_frame(parent, "// FILTER")
def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
if column == "label":
return asset.label
if column == "category":
return asset.category
if column == "inventory_number":
return asset.inventory_number
if column == "status":
return ASSET_STATUS_LABELS.get(asset.status, asset.status)
if column == "holder":
return holder_label
return ""
def _filter_assets(assets: list[Asset], status_filter: str) -> list[Asset]:
if status_filter == "all":
return list(assets)
return [asset for asset in assets if asset.status == status_filter]
class DashboardTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
member_count: int,
findings: list[HousekeeperFinding],
on_housekeeper: Callable[[], None],
):
super().__init__(master, padding=24)
self.member_count = member_count
self.findings = findings
self.on_housekeeper = on_housekeeper
self._build_ui()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
ttk.Label(self, text="SYSTEM OVERVIEW", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
ttk.Label(self, text="Mitgliederverwaltung · lokaler File-Store", style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 22)
)
cards = ttk.Frame(self)
cards.grid(row=2, column=0, sticky="ew")
counts = Counter(finding.severity for finding in self.findings)
values = [
("MITGLIEDER", str(self.member_count), ""),
("ACTION REQUIRED", str(counts["error"]), "CardError.TLabel"),
("DUE SOON", str(counts["warning"] + counts["info"]), "CardWarning.TLabel"),
("DATA INTEGRITY", "OK", ""),
]
for column, (label, value, style) in enumerate(values):
card = ttk.Frame(cards, style="Card.TFrame", padding=18)
card.grid(row=0, column=column, sticky="nsew", padx=(0, 10))
cards.columnconfigure(column, weight=1)
ttk.Label(card, text=label, style="CardTitle.TLabel").pack(anchor="w")
ttk.Label(card, text=value, style=style or "CardValue.TLabel").pack(anchor="w", pady=(8, 0))
ttk.Button(self, text="Hausmeister öffnen", style="Accent.TButton", command=self.on_housekeeper).grid(
row=3, column=0, sticky="w", pady=(24, 0)
)
def update_data(self, member_count: int, findings: list[HousekeeperFinding]) -> None:
self.member_count = member_count
self.findings = findings
for child in self.winfo_children():
child.destroy()
self._build_ui()
class SearchResultsTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
query: str,
members: list[Member],
on_open: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.query = query
self.members = members
self.on_open = on_open
self.on_close = on_close
self._build_ui()
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)
ttk.Label(header, text=f'Suche: "{self.query}"', style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(header, text=f"{len(self.members)} Treffer", 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=3, rowspan=2)
tree = ttk.Treeview(
self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
show="headings",
)
for key, title, width in (
("number", "Nummer", 90),
("first_name", "Vorname", 150),
("last_name", "Nachname", 170),
("nickname", "Nickname", 150),
("email", "E-Mail-Adresse", 260),
("birth", "Geburtsdatum", 110),
("status", "Status", 160),
):
tree.heading(key, text=title)
tree.column(key, width=width, anchor="w")
tree.grid(row=1, column=0, sticky="nsew")
for member in self.members:
tree.insert(
"",
"end",
iid=member.member_id,
values=(
member.member_number,
member.first_name,
member.last_name,
member.nickname,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
tree.bind("<Double-1>", lambda _event: self._open_selected(tree))
tree.bind("<Return>", lambda _event: self._open_selected(tree))
def _open_selected(self, tree: ttk.Treeview) -> None:
selected = tree.selection()
if selected:
self.on_open(selected[0])
class MembersTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
members: list[Member],
on_open: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.members = members
self.on_open = on_open
self.on_close = on_close
self._build_ui()
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)
ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_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)
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
self.tree = ttk.Treeview(
self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
show="headings",
)
self.sort_column = "last_name"
self.sort_descending = False
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox(
filter_row,
textvariable=self.status_filter_var,
state="readonly",
values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()],
width=28,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_members())
for key, title, width in MEMBER_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<Return>", lambda _event: self._open_selected())
self.refresh(self.members)
def refresh(self, members: list[Member]) -> None:
self.members = members
self._render_members()
def _render_members(self) -> None:
self.tree.delete(*self.tree.get_children())
status_filter = _selected_status_filter(self.status_filter_var.get())
filtered_members = _filter_members(self.members, status_filter)
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
if len(filtered_members) == len(self.members):
self.count_var.set(f"{len(filtered_members)} Mitglieder")
else:
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
self._update_tree_headings()
for member in sorted_members:
self.tree.insert(
"",
"end",
iid=member.member_id,
values=(
member.member_number,
member.first_name,
member.last_name,
member.nickname,
member.email,
format_date_for_display(member.birth_date),
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
),
)
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_members()
def _update_tree_headings(self) -> None:
for key, title, _width in MEMBER_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _open_selected(self) -> None:
selected = self.tree.selection()
if selected:
self.on_open(selected[0])
class AssetsTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
assets: list[Asset],
resolve_holder_label: Callable[[str], str],
on_new: Callable[[], None],
on_open: Callable[[str], None],
on_edit: Callable[[str], None],
on_issue: Callable[[str], None],
on_return: Callable[[str], None],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.assets = assets
self.resolve_holder_label = resolve_holder_label
self.on_new = on_new
self.on_open = on_open
self.on_edit = on_edit
self.on_issue = on_issue
self.on_return = on_return
self.on_close = on_close
self.sort_column = "label"
self.sort_descending = False
self.status_filter_var = tk.StringVar(value=ASSET_FILTER_ALL)
self._build_ui()
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)
ttk.Label(header, text="INVENTAR", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
button_row = ttk.Frame(header)
button_row.grid(row=0, column=1, rowspan=2, sticky="e")
ttk.Button(button_row, text="Neues Asset", command=self.on_new).pack(side="left", padx=(0, 8))
ttk.Button(button_row, text="Tab schließen", command=self.on_close).pack(side="left")
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
filter_row.grid(row=0, column=0, sticky="w")
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox(
filter_row,
textvariable=self.status_filter_var,
state="readonly",
values=[ASSET_FILTER_ALL, *ASSET_STATUS_LABELS.values()],
width=22,
)
self.status_filter.grid(row=0, column=1, sticky="w")
self.status_filter.bind("<<ComboboxSelected>>", lambda _event: self._render_assets())
self.tree = ttk.Treeview(
self,
columns=tuple(key for key, _title, _width in ASSET_TABLE_COLUMNS),
show="headings",
)
for key, title, width in ASSET_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", self._open_selected)
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_actions())
actions = ttk.Frame(self)
actions.grid(row=3, column=0, sticky="e", pady=(10, 0))
self.edit_button = ttk.Button(
actions,
text="Bearbeiten",
command=self._edit_selected,
state="disabled",
)
self.edit_button.pack(side="left", padx=(0, 8))
self.open_button = ttk.Button(actions, text="Öffnen", command=self._open_selected, state="disabled")
self.open_button.pack(side="left", padx=(0, 8))
self.issue_button = ttk.Button(
actions,
text="Ausgeben",
command=self._issue_selected,
state="disabled",
)
self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(
actions,
text="Zurücknehmen",
command=self._return_selected,
state="disabled",
)
self.return_button.pack(side="left")
self.refresh(self.assets)
def refresh(self, assets: list[Asset]) -> None:
self.assets = assets
self._render_assets()
def _render_assets(self) -> None:
self.tree.delete(*self.tree.get_children())
status_filter = _selected_asset_filter(self.status_filter_var.get())
filtered_assets = _filter_assets(self.assets, status_filter)
sorted_assets = sorted(
filtered_assets,
key=lambda asset: _asset_table_value(
asset,
self.sort_column,
self.resolve_holder_label(asset.current_holder_member_id),
).casefold(),
reverse=self.sort_descending,
)
if len(filtered_assets) == len(self.assets):
self.count_var.set(f"{len(filtered_assets)} Assets")
else:
self.count_var.set(f"{len(filtered_assets)} / {len(self.assets)} Assets")
self._update_tree_headings()
for asset in sorted_assets:
self.tree.insert(
"",
"end",
iid=asset.asset_id,
values=(
asset.label,
asset.category,
asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status),
self.resolve_holder_label(asset.current_holder_member_id),
),
)
self._update_actions()
def _toggle_sort(self, column: str) -> None:
if self.sort_column == column:
self.sort_descending = not self.sort_descending
else:
self.sort_column = column
self.sort_descending = False
self._render_assets()
def _update_tree_headings(self) -> None:
for key, title, _width in ASSET_TABLE_COLUMNS:
suffix = ""
if key == self.sort_column:
suffix = " v" if self.sort_descending else " ^"
self.tree.heading(
key,
text=f"{title}{suffix}",
command=lambda column=key: self._toggle_sort(column),
)
def _selected_asset_id(self) -> str:
selected = self.tree.selection()
return selected[0] if selected else ""
def _selected_asset(self) -> Asset | None:
selected_asset_id = self._selected_asset_id()
return next((asset for asset in self.assets if asset.asset_id == selected_asset_id), None)
def _update_actions(self) -> None:
asset = self._selected_asset()
if asset is None:
self.edit_button.configure(state="disabled")
self.open_button.configure(state="disabled")
self.issue_button.configure(state="disabled")
self.return_button.configure(state="disabled")
return
self.edit_button.configure(state="normal")
self.open_button.configure(state="normal")
self.issue_button.configure(state="normal" if asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if asset.current_holder_member_id else "disabled")
def _open_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_open(asset_id)
def _edit_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_edit(asset_id)
def _issue_selected(self, _event: tk.Event | None = None) -> None:
asset_id = self._selected_asset_id()
if not asset_id:
asset_id = self.tree.focus()
if asset_id:
self.on_issue(asset_id)
def _return_selected(self) -> None:
asset_id = self._selected_asset_id()
if asset_id:
self.on_return(asset_id)
class HousekeeperTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
findings: list[HousekeeperFinding],
on_open_target: Callable[[HousekeeperFinding], None],
on_refresh: Callable[[], list[HousekeeperFinding]],
on_delete: Callable[[str], list[HousekeeperFinding]],
on_close: Callable[[], None],
):
super().__init__(master, padding=12)
self.findings = findings
self.on_open_target = on_open_target
self.on_refresh = on_refresh
self.on_delete = on_delete
self.on_close = on_close
self._build_ui()
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()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(
header,
text="Regeln prüfen Daten und führen idempotente Aktionen aus",
style="Mono.TLabel",
).grid(row=1, column=0, sticky="w")
ttk.Button(header, text="Neu prüfen", command=self.refresh).grid(
row=0, column=1, rowspan=2, padx=(0, 8)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2)
self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings")
for key, title, width in (
("severity", "Level", 90),
("title", "Vorgang", 330),
("detail", "Details", 390),
("due", "Fällig", 110),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=1, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._show_selected_details())
details = ttk.LabelFrame(self, text="Details", padding=12)
details.grid(row=2, column=0, sticky="ew", pady=(10, 0))
details.columnconfigure(0, weight=1)
self.detail_var = tk.StringVar(value="Eintrag auswählen, um Details anzuzeigen.")
self.detail_label = ttk.Label(
details,
textvariable=self.detail_var,
anchor="nw",
justify="left",
wraplength=800,
style="Status.TLabel",
)
self.detail_label.grid(row=0, column=0, sticky="ew")
self.delete_button = ttk.Button(
details,
text="Task löschen",
command=self._delete_selected,
state="disabled",
)
self.delete_button.grid(row=0, column=1, sticky="ne", padx=(12, 0))
details.bind(
"<Configure>",
lambda event: self.detail_label.configure(wraplength=max(300, event.width - 32)),
)
self._render()
def refresh(self) -> None:
self.findings = self.on_refresh()
self._render()
def _render(self) -> None:
self.tree.delete(*self.tree.get_children())
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.")
self.delete_button.configure(state="disabled")
self.title_var.set(f"HAUSMEISTER · {len(self.findings)} Vorgänge")
for index, finding in enumerate(self.findings):
self.tree.insert(
"",
"end",
iid=str(index),
values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""),
)
def _show_selected_details(self) -> None:
selected = self.tree.selection()
if not selected:
self.detail_var.set("Eintrag auswählen, um Details anzuzeigen.")
self.delete_button.configure(state="disabled")
return
index = int(selected[0])
if index >= len(self.findings):
return
self.detail_var.set(_finding_details(self.findings[index]))
self.delete_button.configure(state="normal" if self.findings[index].key else "disabled")
def _delete_selected(self) -> None:
selected = self.tree.selection()
if not selected:
return
finding = self.findings[int(selected[0])]
if not finding.key or not messagebox.askyesno(
"Hausmeister-Task löschen",
f"Task wirklich löschen?\n\n{finding.title}\n\n"
"Falls die Ursache weiter besteht, wird er beim nächsten Lauf neu angelegt.",
parent=self,
):
return
self.findings = self.on_delete(finding.key)
self._render()
def _open_selected(self) -> None:
selected = self.tree.selection()
if selected:
self.on_open_target(self.findings[int(selected[0])])
def _finding_details(finding: HousekeeperFinding) -> str:
lines = [f"{finding.severity.upper()} · {finding.code}", finding.title]
if finding.target_type == "asset" and finding.asset_id:
lines.append(f"Asset: {finding.asset_id}")
elif finding.member_id:
lines.append(f"Mitglied: {finding.member_id}")
if finding.key:
lines.append(f"Key: {finding.key}")
if finding.due_date:
lines.append(f"Fällig: {format_date_for_display(finding.due_date.isoformat())}")
if finding.detail:
lines.extend(("", finding.detail))
return "\n".join(lines)
def _selected_asset_filter(label: str) -> str:
if label == ASSET_FILTER_ALL:
return "all"
return storage_key(ASSET_STATUS_LABELS, label)