mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-05 19:20:48 +02:00
The club has to keep its member data current, and until now that meant writing
to each member by hand. "Datenüberprüfung anfragen" in the members tab sends one
mail per member, each listing that member's own record: number, name, nickname,
birth date, contact data, address, status, member since, payment frequency, and
the bank details only for members who have any.
Two decisions the record itself forced:
A field with no value is printed as "(nicht hinterlegt)" rather than left out.
The point of the mail is to have gaps filled in, and a missing line is a gap
nobody sees.
The IBAN is masked down to its country code and last four digits. That is enough
to recognise the account, and it keeps a full account number out of a mail the
club sends to dozens of people at once.
Every member is listed as a recipient, with the live memberships that have an
address preselected -- a member who resigned at year's end may still need to
confirm their address, so the board can add them by hand. Members without an
address are skipped and reported instead of failing the run.
Delivery reuses the existing mail machinery: the configured delivery mode, one
SMTP/IMAP connection for the whole run, an archive copy in the member file, a
"data_review_email_sent" event, and the read-only guard before anything is
rendered. A member whose mail fails is reported as a warning and the run
continues -- one bad address must not stop a mailing to the whole club halfway
through. Because the run cannot be taken back, the recipient count is confirmed
once more before it starts.
Subject and text come from a new "Datenüberprüfung" template, editable like the
others, with {{data.sheet}} for the whole record and a {{#data}} block for a
layout of the board's own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
717 lines
27 KiB
Python
717 lines
27 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),
|
|
("last_name", "Nachname", 180),
|
|
("first_name", "Vorname", 160),
|
|
("nickname", "Nickname", 160),
|
|
("email", "E-Mail-Adresse", 270),
|
|
("birth", "Geburtsdatum", 120),
|
|
("status", "Status", 170),
|
|
)
|
|
|
|
STATUS_FILTER_ALL = "Alle"
|
|
MEMBER_FINDING_COLORS = {
|
|
"info": "#d9efff",
|
|
"warning": "#fff3b0",
|
|
"error": "#ffd0d0",
|
|
}
|
|
MEMBER_FINDING_PRIORITY = {"info": 1, "warning": 2, "error": 3}
|
|
ASSET_FILTER_ALL = "Alle"
|
|
ASSET_TABLE_COLUMNS = (
|
|
("label", "Bezeichnung", 260),
|
|
("category", "Kategorie", 140),
|
|
("inventory_number", "Inventarnummer", 140),
|
|
("status", "Status", 140),
|
|
("owner", "Eigentümer", 240),
|
|
("holder", "Gewahrsam", 240),
|
|
("location", "Standort", 180),
|
|
)
|
|
|
|
|
|
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 _member_finding_severities(findings: list[HousekeeperFinding]) -> dict[str, str]:
|
|
severities: dict[str, str] = {}
|
|
for finding in findings:
|
|
if not finding.member_id or finding.severity not in MEMBER_FINDING_PRIORITY:
|
|
continue
|
|
current = severities.get(finding.member_id)
|
|
if current is None or MEMBER_FINDING_PRIORITY[finding.severity] > MEMBER_FINDING_PRIORITY[current]:
|
|
severities[finding.member_id] = finding.severity
|
|
return severities
|
|
|
|
|
|
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, owner_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
|
|
if column == "owner":
|
|
return owner_label
|
|
if column == "location":
|
|
return asset.location
|
|
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],
|
|
findings: list[HousekeeperFinding],
|
|
on_open: Callable[[str], None],
|
|
on_close: Callable[[], None],
|
|
on_data_review: Callable[[], None] | None = None,
|
|
):
|
|
super().__init__(master, padding=12)
|
|
self.members = members
|
|
self.findings = findings
|
|
self.on_open = on_open
|
|
self.on_close = on_close
|
|
self.on_data_review = on_data_review
|
|
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")
|
|
buttons = ttk.Frame(header)
|
|
buttons.grid(row=0, column=1, rowspan=2, sticky="e")
|
|
if self.on_data_review is not None:
|
|
# The mailing goes to every member, so it belongs where every member is
|
|
# listed rather than into the already crowded ribbon.
|
|
ttk.Button(
|
|
buttons, text="Datenüberprüfung anfragen", command=self.on_data_review
|
|
).pack(side="left", padx=(0, 8))
|
|
ttk.Button(buttons, 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")
|
|
self.tree = ttk.Treeview(
|
|
self,
|
|
columns=tuple(column for column, _title, _width in MEMBER_TABLE_COLUMNS),
|
|
show="headings",
|
|
)
|
|
self.sort_column = "last_name"
|
|
self.sort_descending = False
|
|
self.status_filter_var = tk.StringVar(value=MEMBERSHIP_STATUS_LABELS["active"])
|
|
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")
|
|
for severity, color in MEMBER_FINDING_COLORS.items():
|
|
self.tree.tag_configure(f"finding-{severity}", background=color, foreground="#202020")
|
|
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, self.findings)
|
|
|
|
def refresh(self, members: list[Member], findings: list[HousekeeperFinding]) -> None:
|
|
self.members = members
|
|
self.findings = findings
|
|
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)
|
|
finding_severities = _member_finding_severities(self.findings)
|
|
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:
|
|
severity = finding_severities.get(member.member_id)
|
|
self.tree.insert(
|
|
"",
|
|
"end",
|
|
iid=member.member_id,
|
|
tags=(f"finding-{severity}",) if severity else (),
|
|
values=(
|
|
member.member_number,
|
|
member.last_name,
|
|
member.first_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),
|
|
self._owner_label(asset),
|
|
).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._owner_label(asset),
|
|
self.resolve_holder_label(asset.current_holder_member_id),
|
|
asset.location,
|
|
),
|
|
)
|
|
self._update_actions()
|
|
|
|
def _owner_label(self, asset: Asset) -> str:
|
|
if asset.owner_type == "club":
|
|
return "Verein"
|
|
if asset.owner_type == "member":
|
|
return self.resolve_holder_label(asset.owner_member_id)
|
|
return asset.owner_name or "Extern"
|
|
|
|
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", "due", "title", "detail"), show="headings")
|
|
for key, title, width in (
|
|
("severity", "Level", 90),
|
|
("due", "Fällig", 100),
|
|
("title", "Vorgang", 330),
|
|
("detail", "Details", 390),
|
|
):
|
|
self.tree.heading(key, text=title)
|
|
self.tree.column(key, width=width, anchor="w", stretch=key == "detail")
|
|
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.due_date or "", finding.title, finding.detail),
|
|
)
|
|
|
|
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)
|