mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
587 lines
21 KiB
Python
587 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from tkinter import messagebox, ttk
|
|
|
|
from ccma import __version__
|
|
from ccma.config import AppConfig
|
|
from ccma.domain.models import Asset, HousekeeperFinding, Member
|
|
from ccma.services.housekeeper import Housekeeper
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
from ccma.ui.asset_tab import AssetTab
|
|
from ccma.ui.claim_tab import ClaimTab
|
|
from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
|
|
from ccma.ui.icons import IconStore
|
|
from ccma.ui.member_tab import MemberTab
|
|
from ccma.ui.options_dialog import OptionsDialog
|
|
from ccma.ui.sepa_dialog import SepaExportDialog
|
|
from ccma.ui.theme import load_theme
|
|
from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
|
|
|
|
|
|
class TabManager:
|
|
def __init__(self, notebook: ttk.Notebook):
|
|
self.notebook = notebook
|
|
self.tabs: dict[str, tk.Widget] = {}
|
|
self.images: dict[str, tk.PhotoImage] = {}
|
|
self.icon_names: dict[str, str] = {}
|
|
|
|
def add(
|
|
self,
|
|
key: str,
|
|
widget: tk.Widget,
|
|
title: str,
|
|
*,
|
|
select: bool = True,
|
|
image: tk.PhotoImage | None = None,
|
|
icon_name: str | None = None,
|
|
) -> None:
|
|
self.tabs[key] = widget
|
|
options: dict[str, object] = {"text": title, "padding": (6, 3)}
|
|
if image:
|
|
options.update(image=image, compound="left")
|
|
self.images[key] = image
|
|
if icon_name:
|
|
self.icon_names[key] = icon_name
|
|
self.notebook.add(widget, **options)
|
|
if select:
|
|
self.notebook.select(widget)
|
|
|
|
def focus(self, key: str) -> bool:
|
|
widget = self.tabs.get(key)
|
|
if widget and widget.winfo_exists():
|
|
self.notebook.select(widget)
|
|
return True
|
|
self.tabs.pop(key, None)
|
|
return False
|
|
|
|
def close(self, key: str) -> None:
|
|
widget = self.tabs.pop(key, None)
|
|
self.images.pop(key, None)
|
|
self.icon_names.pop(key, None)
|
|
if widget and widget.winfo_exists():
|
|
self.notebook.forget(widget)
|
|
widget.destroy()
|
|
|
|
def refresh_icons(self, icons: IconStore) -> None:
|
|
for key, icon_name in self.icon_names.items():
|
|
widget = self.tabs.get(key)
|
|
image = icons.get(icon_name, 16)
|
|
if widget and widget.winfo_exists() and image:
|
|
self.images[key] = image
|
|
self.notebook.tab(widget, image=image, compound="left")
|
|
|
|
|
|
class MainWindow(ttk.Frame):
|
|
def __init__(
|
|
self,
|
|
master: tk.Tk,
|
|
repository: MemberRepository,
|
|
config: AppConfig,
|
|
findings: list[HousekeeperFinding],
|
|
validation_errors: list[str],
|
|
):
|
|
super().__init__(master, padding=(12, 8))
|
|
self.repository = repository
|
|
self.config = config
|
|
self.findings = findings
|
|
self.validation_errors = validation_errors
|
|
self.search_var = tk.StringVar()
|
|
self.status_var = tk.StringVar(value="Bereit.")
|
|
self.search_counter = 1
|
|
self.icons = IconStore(self)
|
|
self.ribbon: ttk.Frame | None = None
|
|
self.housekeeper_button: ttk.Button | None = None
|
|
self._build_ui()
|
|
if validation_errors:
|
|
self.after_idle(self._show_validation_warning)
|
|
|
|
def _build_ui(self) -> None:
|
|
self.columnconfigure(0, weight=1)
|
|
self.rowconfigure(1, weight=1)
|
|
self._build_ribbon()
|
|
self.notebook = ttk.Notebook(self)
|
|
self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0))
|
|
self.tabs = TabManager(self.notebook)
|
|
self.dashboard = DashboardTab(
|
|
self.notebook,
|
|
self.repository.member_count(),
|
|
self.findings,
|
|
self.open_housekeeper,
|
|
)
|
|
self.tabs.add(
|
|
"dashboard",
|
|
self.dashboard,
|
|
"Dashboard",
|
|
image=self.icons.get("view-dashboard", 16),
|
|
icon_name="view-dashboard",
|
|
)
|
|
status = ttk.Frame(self, padding=(6, 5))
|
|
status.grid(row=2, column=0, sticky="ew")
|
|
status.columnconfigure(0, weight=1)
|
|
ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid(
|
|
row=0, column=0, sticky="w"
|
|
)
|
|
ttk.Label(
|
|
status,
|
|
text=f"STORE {self.repository.root} · VERSION {__version__}",
|
|
style="Status.TLabel",
|
|
).grid(row=0, column=1, sticky="e")
|
|
|
|
def _build_ribbon(self) -> None:
|
|
ribbon = ttk.Frame(self, style="Ribbon.TFrame")
|
|
self.ribbon = ribbon
|
|
ribbon.grid(row=0, column=0, sticky="ew")
|
|
ribbon.columnconfigure(2, weight=1)
|
|
title = ttk.Frame(ribbon)
|
|
title.grid(row=0, column=0, sticky="w", padx=(0, 18))
|
|
ttk.Label(title, text="CCMA", style="AppTitle.TLabel").pack(anchor="w")
|
|
ttk.Label(title, text="// MEMBER ADMIN", style="Mono.TLabel").pack(anchor="w")
|
|
search = ttk.Frame(ribbon)
|
|
search.grid(row=0, column=1, sticky="w")
|
|
entry = ttk.Entry(search, textvariable=self.search_var, font=("TkDefaultFont", 11), width=40)
|
|
entry.grid(row=0, column=0, sticky="w")
|
|
entry.bind("<Return>", lambda _event: self.search())
|
|
entry.bind("<Control-l>", lambda _event: entry.focus_set())
|
|
ttk.Label(search, text="Name · E-Mail · Geburtsdatum · Mitgliedsnummer", style="Mono.TLabel").grid(
|
|
row=1, column=0, sticky="w", pady=(3, 0)
|
|
)
|
|
search_icon = self.icons.get("magnify", 24)
|
|
search_button = ttk.Button(
|
|
search,
|
|
text="Suchen",
|
|
image=search_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.search,
|
|
)
|
|
search_button.image = search_icon
|
|
search_button.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(6, 0))
|
|
|
|
ttk.Separator(ribbon, orient="vertical").grid(
|
|
row=0,
|
|
column=3,
|
|
sticky="ns",
|
|
padx=16,
|
|
)
|
|
actions = ttk.Frame(ribbon)
|
|
actions.grid(row=0, column=4, sticky="e")
|
|
members_icon = self.icons.get("account-group", 24) or self.icons.get("account-multiple", 24)
|
|
members_button = ttk.Button(
|
|
actions,
|
|
text="Mitglieder",
|
|
image=members_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.open_members,
|
|
)
|
|
members_button.image = members_icon
|
|
members_button.pack(side="left", padx=(0, 6))
|
|
assets_icon = self.icons.get("key-variant", 24) or self.icons.get("package-variant-closed", 24)
|
|
assets_button = ttk.Button(
|
|
actions,
|
|
text="Inventar",
|
|
image=assets_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.open_assets,
|
|
)
|
|
assets_button.image = assets_icon
|
|
assets_button.pack(side="left", padx=(0, 6))
|
|
sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24)
|
|
sepa_button = ttk.Button(
|
|
actions, text="SEPA", image=sepa_icon, compound="top", width=14,
|
|
command=self.open_sepa_export,
|
|
)
|
|
sepa_button.image = sepa_icon
|
|
sepa_button.pack(side="left", padx=(0, 6))
|
|
new_icon = self.icons.get("account-plus", 24)
|
|
new_button = ttk.Button(
|
|
actions,
|
|
text="Neues Mitglied",
|
|
image=new_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.new_member,
|
|
)
|
|
new_button.image = new_icon
|
|
new_button.pack(side="left", padx=(0, 6))
|
|
housekeeper_icon = self.icons.get("broom", 24) or self.icons.get("clipboard-check", 24)
|
|
self.housekeeper_button = ttk.Button(
|
|
actions,
|
|
text=f"Hausmeister ({len(self.findings)})",
|
|
image=housekeeper_icon,
|
|
compound="top",
|
|
width=14,
|
|
style="Accent.TButton",
|
|
command=self.open_housekeeper,
|
|
)
|
|
self.housekeeper_button.image = housekeeper_icon
|
|
self.housekeeper_button.pack(side="left", padx=(0, 6))
|
|
theme_icon = self.icons.get("theme-light-dark", 24)
|
|
theme_button = ttk.Button(
|
|
actions,
|
|
text="Light/Dark",
|
|
image=theme_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.toggle_theme,
|
|
)
|
|
theme_button.image = theme_icon
|
|
theme_button.pack(side="left", padx=(0, 6))
|
|
options_icon = self.icons.get("cog", 24)
|
|
options_button = ttk.Button(
|
|
actions,
|
|
text="Optionen",
|
|
image=options_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=self.open_options,
|
|
)
|
|
options_button.image = options_icon
|
|
options_button.pack(side="left", padx=(0, 6))
|
|
exit_icon = self.icons.get("logout", 24) or self.icons.get("exit-to-app", 24)
|
|
exit_button = ttk.Button(
|
|
actions,
|
|
text="Beenden",
|
|
image=exit_icon,
|
|
compound="top",
|
|
width=14,
|
|
command=getattr(self.master, "close", self.master.destroy),
|
|
)
|
|
exit_button.image = exit_icon
|
|
exit_button.pack(side="left")
|
|
|
|
def search(self) -> None:
|
|
query = self.search_var.get().strip()
|
|
if not query:
|
|
self.status_var.set("Bitte einen Suchbegriff eingeben.")
|
|
return
|
|
results = self.repository.search(query)
|
|
self.search_var.set("")
|
|
if len(results) == 1:
|
|
self.open_member(results[0].member_id)
|
|
self.status_var.set(f"Eindeutiger Treffer: {results[0].display_name}")
|
|
return
|
|
key = f"search:{self.search_counter}"
|
|
self.search_counter += 1
|
|
tab = SearchResultsTab(
|
|
self.notebook,
|
|
query,
|
|
results,
|
|
self.open_member,
|
|
lambda: self.tabs.close(key),
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
f"Suche: {query[:20]}",
|
|
image=self.icons.get("format-list-text", 16),
|
|
icon_name="format-list-text",
|
|
)
|
|
self.status_var.set(f"{len(results)} Treffer für {query}")
|
|
|
|
def open_member(self, member_id: str) -> None:
|
|
key = f"member:{member_id}"
|
|
if self.tabs.focus(key):
|
|
return
|
|
try:
|
|
member = self.repository.get_member(member_id)
|
|
except RepositoryError as exc:
|
|
self.status_var.set(str(exc))
|
|
messagebox.showerror("Mitgliederakte beschädigt", str(exc), parent=self)
|
|
return
|
|
tab = MemberTab(
|
|
self.notebook,
|
|
self.repository,
|
|
member_id,
|
|
on_close=lambda: self.tabs.close(key),
|
|
on_changed=self.refresh_overview,
|
|
on_open_claim=self.open_claim,
|
|
on_open_assets=self.open_assets,
|
|
on_open_asset=self.open_asset,
|
|
on_return_asset=self.return_asset,
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
member.display_name or member.member_number,
|
|
image=self.icons.get("account", 16),
|
|
icon_name="account",
|
|
)
|
|
|
|
def open_claim(self, member_id: str, claim_id: str) -> None:
|
|
key = f"claim:{member_id}:{claim_id}"
|
|
if self.tabs.focus(key):
|
|
return
|
|
try:
|
|
_data, claim = self.repository.get_claim(member_id, claim_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Forderung konnte nicht geöffnet werden", str(exc), parent=self)
|
|
return
|
|
tab = ClaimTab(
|
|
self.notebook,
|
|
self.repository,
|
|
member_id,
|
|
claim_id,
|
|
on_close=lambda: self.tabs.close(key),
|
|
on_changed=lambda: self._claim_changed(member_id),
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
str(claim.get("title") or "Forderung"),
|
|
image=self.icons.get("receipt", 16) or self.icons.get("file-document", 16),
|
|
icon_name="receipt",
|
|
)
|
|
|
|
def open_asset(self, asset_id: str) -> None:
|
|
key = f"asset:{asset_id}"
|
|
if self.tabs.focus(key):
|
|
return
|
|
try:
|
|
asset = self.repository.get_asset(asset_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Asset konnte nicht geöffnet werden", str(exc), parent=self)
|
|
return
|
|
tab = AssetTab(
|
|
self.notebook,
|
|
self.repository,
|
|
asset_id,
|
|
on_close=lambda: self.tabs.close(key),
|
|
on_changed=self.refresh_overview,
|
|
on_open_member=self.open_member,
|
|
on_issue_asset=self.issue_asset,
|
|
on_return_asset=self.return_asset,
|
|
on_open_claim=self.open_claim,
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
asset.label or asset.asset_id,
|
|
image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16),
|
|
icon_name="key-variant",
|
|
)
|
|
|
|
def _claim_changed(self, member_id: str) -> None:
|
|
member_tab = self.tabs.tabs.get(f"member:{member_id}")
|
|
if isinstance(member_tab, MemberTab) and member_tab.winfo_exists():
|
|
member_tab.refresh()
|
|
self.refresh_overview()
|
|
|
|
def open_members(self) -> None:
|
|
key = "members"
|
|
if self.tabs.focus(key):
|
|
return
|
|
tab = MembersTab(
|
|
self.notebook,
|
|
self.repository.list_members(),
|
|
self.findings,
|
|
self.open_member,
|
|
lambda: self.tabs.close(key),
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
"Mitglieder",
|
|
image=self.icons.get("account-group", 16) or self.icons.get("account-multiple", 16),
|
|
icon_name="account-group",
|
|
)
|
|
|
|
def open_assets(self) -> None:
|
|
key = "assets"
|
|
if self.tabs.focus(key):
|
|
return
|
|
tab = AssetsTab(
|
|
self.notebook,
|
|
self.repository.list_assets(),
|
|
self._member_reference_label,
|
|
self.new_asset,
|
|
self.open_asset,
|
|
self.edit_asset,
|
|
self.issue_asset,
|
|
self.return_asset,
|
|
lambda: self.tabs.close(key),
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
"Inventar",
|
|
image=self.icons.get("key-variant", 16) or self.icons.get("package-variant-closed", 16),
|
|
icon_name="key-variant",
|
|
)
|
|
|
|
def open_sepa_export(self) -> None:
|
|
SepaExportDialog(self, self.repository)
|
|
|
|
def new_member(self) -> None:
|
|
NewMemberDialog(self, self.repository, self._member_created)
|
|
|
|
def new_asset(self) -> None:
|
|
NewAssetDialog(self, self.repository, self._asset_created)
|
|
|
|
def _member_created(self, member: Member) -> None:
|
|
self.refresh_overview()
|
|
self.open_member(member.member_id)
|
|
self.status_var.set(f"Mitgliederakte für {member.display_name} angelegt.")
|
|
|
|
def _asset_created(self, asset: Asset) -> None:
|
|
self.refresh_overview()
|
|
self.open_assets()
|
|
self.status_var.set(f"Asset angelegt: {asset.label}")
|
|
|
|
def issue_asset(self, asset_id: str, preselected_member_id: str = "") -> None:
|
|
try:
|
|
IssueAssetDialog(
|
|
self,
|
|
self.repository,
|
|
asset_id,
|
|
self._asset_changed,
|
|
preselected_member_id=preselected_member_id,
|
|
)
|
|
except (RepositoryError, tk.TclError) as exc:
|
|
messagebox.showerror("Asset konnte nicht ausgegeben werden", str(exc), parent=self)
|
|
|
|
def edit_asset(self, asset_id: str) -> None:
|
|
try:
|
|
EditAssetDialog(
|
|
self,
|
|
self.repository,
|
|
asset_id,
|
|
self._asset_changed,
|
|
)
|
|
except (RepositoryError, tk.TclError) as exc:
|
|
messagebox.showerror("Asset konnte nicht bearbeitet werden", str(exc), parent=self)
|
|
|
|
def return_asset(self, asset_id: str) -> None:
|
|
try:
|
|
asset = self.repository.get_asset(asset_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Asset konnte nicht geladen werden", str(exc), parent=self)
|
|
return
|
|
if not messagebox.askyesno(
|
|
"Asset zurücknehmen",
|
|
f"{asset.label}\n\nDieses Asset wirklich als zurückgegeben markieren?",
|
|
parent=self,
|
|
):
|
|
return
|
|
try:
|
|
asset = self.repository.return_asset(asset_id)
|
|
except RepositoryError as exc:
|
|
messagebox.showerror("Rückgabe fehlgeschlagen", str(exc), parent=self)
|
|
return
|
|
self._asset_changed(asset)
|
|
|
|
def _asset_changed(self, asset: Asset) -> None:
|
|
self.refresh_overview()
|
|
self.status_var.set(f"Asset aktualisiert: {asset.label}")
|
|
|
|
def open_housekeeper(self) -> None:
|
|
key = "housekeeper"
|
|
if self.tabs.focus(key):
|
|
return
|
|
tab = HousekeeperTab(
|
|
self.notebook,
|
|
self.findings,
|
|
self._open_housekeeper_target,
|
|
self.run_housekeeper,
|
|
self.delete_housekeeper_task,
|
|
lambda: self.tabs.close(key),
|
|
)
|
|
self.tabs.add(
|
|
key,
|
|
tab,
|
|
"Hausmeister",
|
|
image=self.icons.get("broom", 16),
|
|
icon_name="broom",
|
|
)
|
|
|
|
def run_housekeeper(self) -> list[HousekeeperFinding]:
|
|
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
|
|
self.refresh_overview(run_housekeeper=False)
|
|
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
|
return self.findings
|
|
|
|
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
|
|
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
|
self.refresh_overview(run_housekeeper=False)
|
|
self.status_var.set("Hausmeister-Task gelöscht.")
|
|
return self.findings
|
|
|
|
def refresh_overview(self, *, run_housekeeper: bool = True) -> None:
|
|
if run_housekeeper:
|
|
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
|
|
self.dashboard.update_data(self.repository.member_count(), self.findings)
|
|
members_tab = self.tabs.tabs.get("members")
|
|
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
|
|
members_tab.refresh(self.repository.list_members(), self.findings)
|
|
assets_tab = self.tabs.tabs.get("assets")
|
|
if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists():
|
|
assets_tab.refresh(self.repository.list_assets())
|
|
for key, widget in self.tabs.tabs.items():
|
|
if key.startswith("member:") and isinstance(widget, MemberTab) and widget.winfo_exists():
|
|
widget.refresh()
|
|
if key.startswith("asset:") and isinstance(widget, AssetTab) and widget.winfo_exists():
|
|
widget.refresh()
|
|
if self.housekeeper_button and self.housekeeper_button.winfo_exists():
|
|
self.housekeeper_button.configure(text=f"Hausmeister ({len(self.findings)})")
|
|
|
|
def toggle_theme(self) -> None:
|
|
self.config.theme_mode = "light" if self.config.theme_mode == "dark" else "dark"
|
|
self.config.save()
|
|
load_theme(self.master, self.config.theme_mode)
|
|
self._refresh_ribbon_icons()
|
|
self.status_var.set(f"Theme: {self.config.theme_mode}.")
|
|
|
|
def open_options(self) -> None:
|
|
OptionsDialog(self, self.config, self.repository, self._options_saved)
|
|
|
|
def _options_saved(self, store_changed: bool) -> None:
|
|
load_theme(self.master, self.config.theme_mode)
|
|
self._refresh_ribbon_icons()
|
|
if store_changed:
|
|
self.status_var.set("Mitglieder-Store geändert. Die Änderung wird beim Neustart aktiv.")
|
|
messagebox.showinfo(
|
|
"Neustart erforderlich",
|
|
"Der neue Mitglieder-Store wird beim nächsten Programmstart geöffnet.",
|
|
parent=self,
|
|
)
|
|
else:
|
|
self.status_var.set("Optionen gespeichert.")
|
|
|
|
def _refresh_ribbon_icons(self) -> None:
|
|
if self.ribbon and self.ribbon.winfo_exists():
|
|
self.ribbon.destroy()
|
|
self.icons = IconStore(self)
|
|
self._build_ribbon()
|
|
self.tabs.refresh_icons(self.icons)
|
|
|
|
def _show_validation_warning(self) -> None:
|
|
display_errors = [
|
|
item for item in self.validation_errors if "Hash fehlt oder stimmt nicht" not in item
|
|
]
|
|
if not display_errors:
|
|
return
|
|
messagebox.showwarning(
|
|
"Datenprüfung",
|
|
"Der Store enthält ungültige Akten:\n\n" + "\n".join(display_errors[:12]),
|
|
parent=self,
|
|
)
|
|
|
|
def _open_housekeeper_target(self, finding: HousekeeperFinding) -> None:
|
|
if finding.target_type == "asset" and finding.asset_id:
|
|
self.open_asset(finding.asset_id)
|
|
return
|
|
if finding.member_id:
|
|
self.open_member(finding.member_id)
|
|
|
|
def _member_reference_label(self, member_id: str) -> str:
|
|
if not member_id:
|
|
return "—"
|
|
try:
|
|
member = self.repository.get_member(member_id)
|
|
except RepositoryError:
|
|
return member_id
|
|
prefix = member.member_number or member.member_id
|
|
return f"{prefix} · {member.display_name or member.member_id}"
|