mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
418 lines
16 KiB
Python
418 lines
16 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 MEMBERSHIP_STATUS_LABELS, HousekeeperFinding, Member
|
|
from ccma.ui.labels import storage_key
|
|
|
|
|
|
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"
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 = ttk.Frame(self, padding=(12, 10))
|
|
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
|
filters.columnconfigure(1, weight=1)
|
|
ttk.Label(filters, text="Filter", style="Mono.TLabel").grid(row=0, column=0, sticky="w", padx=(0, 16))
|
|
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(filters, text="Status").grid(row=0, column=1, sticky="w", padx=(0, 8))
|
|
self.status_filter = ttk.Combobox(
|
|
filters,
|
|
textvariable=self.status_filter_var,
|
|
state="readonly",
|
|
values=[STATUS_FILTER_ALL, *MEMBERSHIP_STATUS_LABELS.values()],
|
|
width=28,
|
|
)
|
|
self.status_filter.grid(row=0, column=2, 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 HousekeeperTab(ttk.Frame):
|
|
def __init__(
|
|
self,
|
|
master: tk.Misc,
|
|
findings: list[HousekeeperFinding],
|
|
on_open_member: Callable[[str], 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_member = on_open_member
|
|
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_member(self.findings[int(selected[0])].member_id)
|
|
|
|
|
|
def _finding_details(finding: HousekeeperFinding) -> str:
|
|
lines = [f"{finding.severity.upper()} · {finding.code}", finding.title]
|
|
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)
|