Files
aza/AzA march 2026/aza_bibliothek_ui.py
2026-06-10 22:55:03 +02:00

464 lines
18 KiB
Python

# -*- coding: utf-8 -*-
"""AzA Bibliothek — Werkzeugfenster im AzA-Blau/Karten-Stil (wie KI-Guthaben).
Bibliothek ist NUR für Medikamente, Begriffe, Personen/Signaturen, Korrekturen.
Prompt-Vorlagen sind ein eigenes Modul und NICHT Teil dieser UI.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any, Callable, List
from aza_bibliothek import (
CATEGORY_LABELS,
CATEGORY_TO_STORE,
PRIVATE_UI_CATEGORIES,
PUBLIC_UI_CATEGORIES,
adopt_public_entry,
delete_private_correction,
list_private_correction_rows,
list_public_entries,
save_private_correction,
toggle_private_correction_active,
)
# AzA-Design (identisch KI-Guthaben-Fenster)
_BG = "#E8F4FA"
_HDR_BG = "#1A4D6D"
_HDR_FG = "#FFFFFF"
_SUB_BG = "#D0E8F5"
_CARD_BG = "#FFFFFF"
_CARD_BD = "#C8D8E8"
_ACCENT = "#5B8DB3"
_ACCENT_HOV = "#4A7A9E"
_TEXT = "#1A3D55"
_TEXT_SUB = "#607890"
_WARN = "#B06000"
_FF = "Segoe UI"
# Fenstergröße (großes AzA-Werkzeugfenster)
_WIN_W = 1300
_WIN_H = 880
_WIN_MIN_W = 1100
_WIN_MIN_H = 760
def _pill(parent, text: str, *, active: bool, command: Callable[[], None]):
lbl = tk.Label(
parent, text=text, font=(_FF, 10),
bg=_ACCENT if active else _CARD_BG,
fg="#FFFFFF" if active else _TEXT,
padx=12, pady=7, cursor="hand2",
highlightthickness=1,
highlightbackground=_CARD_BD,
anchor="w",
)
lbl.bind("<Button-1>", lambda _e: command())
return lbl
def _btn(parent, text: str, command, *, primary: bool = False, danger: bool = False):
bg = "#C04040" if danger else (_ACCENT if primary else _CARD_BG)
fg = "#FFFFFF" if (primary or danger) else _ACCENT
return tk.Button(
parent, text=text, command=command,
font=(_FF, 10), bg=bg, fg=fg,
activebackground=_ACCENT_HOV if primary else _SUB_BG,
relief="flat", padx=14, pady=6, cursor="hand2",
highlightthickness=1, highlightbackground=_CARD_BD,
)
def _center_top(win, w: int, h: int) -> None:
"""Zentriert das Fenster horizontal, leicht oben (analog AzA-Werkzeugfenster)."""
try:
win.update_idletasks()
sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
x = max(0, (sw - w) // 2)
y = max(0, int(sh * 0.06))
win.geometry(f"{w}x{h}+{x}+{y}")
except Exception:
win.geometry(f"{w}x{h}")
def _bring_to_front(win) -> None:
"""Kurz topmost, dann wieder normal — wie bestehende AzA-Tools."""
try:
win.lift()
win.attributes("-topmost", True)
win.after(300, lambda: _safe_untopmost(win))
win.focus_force()
except Exception:
pass
def _safe_untopmost(win) -> None:
try:
if win.winfo_exists():
win.attributes("-topmost", False)
except Exception:
pass
def open_bibliothek_window(app: Any) -> None:
"""Singleton Bibliothek-Fenster."""
existing = getattr(app, "_bibliothek_win", None)
if existing is not None:
try:
if existing.winfo_exists():
existing.lift()
_bring_to_front(existing)
return
except Exception:
pass
try:
app._sync_practice_users_to_bibliothek()
except Exception:
pass
win = tk.Toplevel(app)
app._bibliothek_win = win
win.title("Bibliothek")
win.configure(bg=_BG)
win.minsize(_WIN_MIN_W, _WIN_MIN_H)
_center_top(win, _WIN_W, _WIN_H)
try:
win.transient(app)
except Exception:
pass
state = {"scope": "private", "category": "medication", "selected": None}
# ── Header ────────────────────────────────────────────────────────────────
hdr = tk.Frame(win, bg=_HDR_BG)
hdr.pack(fill="x")
tk.Label(hdr, text="Bibliothek", font=(_FF, 15, "bold"),
bg=_HDR_BG, fg=_HDR_FG, padx=20, pady=14).pack(side="left")
close_x = tk.Label(hdr, text="", font=(_FF, 12), bg=_HDR_BG, fg="#A0C4D8",
cursor="hand2", padx=18)
close_x.pack(side="right")
sub = tk.Frame(win, bg=_SUB_BG)
sub.pack(fill="x")
tk.Label(
sub,
text="Medizinische Begriffe, Medikamente, Personen/Signaturen und Korrekturen verwalten. "
"Eigene Einträge werden bevorzugt verwendet. Öffentliche Einträge können übernommen werden.",
font=(_FF, 9), bg=_SUB_BG, fg=_TEXT_SUB,
padx=20, pady=8, wraplength=_WIN_W - 60, justify="left",
).pack(anchor="w")
body = tk.Frame(win, bg=_BG)
body.pack(fill="both", expand=True, padx=16, pady=12)
# ── Linke Navigation ──────────────────────────────────────────────────────
nav = tk.Frame(body, bg=_CARD_BG, highlightthickness=1, highlightbackground=_CARD_BD, width=260)
nav.pack(side="left", fill="y", padx=(0, 14))
nav.pack_propagate(False)
nav_inner = tk.Frame(nav, bg=_CARD_BG, padx=12, pady=14)
nav_inner.pack(fill="both", expand=True)
tk.Label(nav_inner, text="Bibliothek", font=(_FF, 9, "bold"),
bg=_CARD_BG, fg=_TEXT_SUB, anchor="w").pack(fill="x", pady=(0, 4))
scope_pills: dict[str, tk.Label] = {}
scope_frame = tk.Frame(nav_inner, bg=_CARD_BG)
scope_frame.pack(fill="x")
tk.Label(nav_inner, text="Kategorien", font=(_FF, 9, "bold"),
bg=_CARD_BG, fg=_TEXT_SUB, anchor="w").pack(fill="x", pady=(14, 4))
cat_frame = tk.Frame(nav_inner, bg=_CARD_BG)
cat_frame.pack(fill="x")
# ── Rechte Karte ──────────────────────────────────────────────────────────
card = tk.Frame(body, bg=_CARD_BG, highlightthickness=1, highlightbackground=_CARD_BD)
card.pack(side="left", fill="both", expand=True)
title_lbl = tk.Label(card, text="", font=(_FF, 13, "bold"), bg=_CARD_BG, fg=_TEXT, anchor="w")
title_lbl.pack(fill="x", padx=18, pady=(14, 2))
subtitle_lbl = tk.Label(card, text="", font=(_FF, 9), bg=_CARD_BG, fg=_TEXT_SUB,
anchor="w", wraplength=_WIN_W - 360, justify="left")
subtitle_lbl.pack(fill="x", padx=18, pady=(0, 10))
search_var = tk.StringVar()
search_row = tk.Frame(card, bg=_CARD_BG)
search_row.pack(fill="x", padx=18, pady=(0, 8))
tk.Label(search_row, text="Suchen:", bg=_CARD_BG, fg=_TEXT, font=(_FF, 10)).pack(side="left")
search_ent = tk.Entry(search_row, textvariable=search_var, font=(_FF, 11), width=44,
relief="flat", highlightthickness=1, highlightbackground=_CARD_BD)
search_ent.pack(side="left", padx=10)
hint_lbl = tk.Label(
card,
text="Diese Bibliothek hilft bei der korrekten Schreibweise. Sie ersetzt keine medizinische Prüfung.",
font=(_FF, 9), bg="#FFF8EE", fg=_WARN, padx=12, pady=7, anchor="w",
)
hint_lbl.pack(fill="x", padx=18, pady=(0, 8))
list_frame = tk.Frame(card, bg=_CARD_BG)
list_frame.pack(fill="both", expand=True, padx=18, pady=4)
listbox = tk.Listbox(
list_frame, font=(_FF, 11), height=16,
bg="#FAFCFE", fg=_TEXT, selectbackground=_ACCENT,
selectforeground="#FFFFFF", relief="flat",
highlightthickness=1, highlightbackground=_CARD_BD,
)
scroll = ttk.Scrollbar(list_frame, orient="vertical", command=listbox.yview)
listbox.configure(yscrollcommand=scroll.set)
listbox.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
detail = tk.Frame(card, bg=_CARD_BG)
detail.pack(fill="x", padx=18, pady=10)
detail_vars = {"falsch": tk.StringVar(), "richtig": tk.StringVar(), "note": tk.StringVar()}
tk.Label(detail, text="Hörvariante / Begriff:", bg=_CARD_BG, font=(_FF, 9)).grid(row=0, column=0, sticky="w")
falsch_ent = tk.Entry(detail, textvariable=detail_vars["falsch"], width=34, font=(_FF, 11))
falsch_ent.grid(row=0, column=1, padx=8)
tk.Label(detail, text="Bevorzugte Schreibweise:", bg=_CARD_BG, font=(_FF, 9)).grid(row=1, column=0, sticky="w", pady=6)
richtig_ent = tk.Entry(detail, textvariable=detail_vars["richtig"], width=34, font=(_FF, 11))
richtig_ent.grid(row=1, column=1, padx=8, pady=6)
meta_lbl = tk.Label(detail, textvariable=detail_vars["note"], bg=_CARD_BG, fg=_TEXT_SUB,
font=(_FF, 9), wraplength=_WIN_W - 420, justify="left")
meta_lbl.grid(row=2, column=0, columnspan=2, sticky="w", pady=(4, 0))
btn_row = tk.Frame(card, bg=_CARD_BG)
btn_row.pack(fill="x", padx=18, pady=(6, 16))
rows_cache: List[dict] = []
def _close():
try:
app._bibliothek_win = None
except Exception:
pass
try:
win.destroy()
except Exception:
pass
close_x.bind("<Button-1>", lambda _e: _close())
win.protocol("WM_DELETE_WINDOW", _close)
cat_pills: dict[str, tk.Label] = {}
def _rebuild_category_pills():
for w in cat_frame.winfo_children():
w.destroy()
cat_pills.clear()
cats = PRIVATE_UI_CATEGORIES if state["scope"] == "private" else PUBLIC_UI_CATEGORIES
for cat in cats:
p = _pill(cat_frame, CATEGORY_LABELS.get(cat, cat),
active=(cat == state["category"]),
command=lambda c=cat: _set_category(c))
p.pack(fill="x", pady=2)
cat_pills[cat] = p
def _visible_rows() -> List[dict]:
q = (search_var.get() or "").strip().lower()
out = []
for row in rows_cache:
if q and q not in _format_row_label(row).lower():
continue
out.append(row)
return out
def _format_row_label(row: dict) -> str:
if row.get("scope") == "public" and row.get("category") == "medication":
sub = row.get("active_substance")
base = row.get("preferred_spelling") or row.get("term") or ""
return f"{base} ({sub}) · AzA kuratiert" if sub else f"{base} · AzA kuratiert"
falsch = row.get("term") or ""
richtig = row.get("preferred_spelling") or ""
inact = "" if row.get("active", True) else " [inaktiv]"
src = row.get("source") or ""
if falsch and richtig and falsch.lower() != richtig.lower():
return f"{falsch}{richtig}{inact} ({src})"
return f"{richtig or falsch}{inact} ({src})"
def _load_rows() -> List[dict]:
if state["scope"] == "private":
from aza_persistence import load_korrekturen
rows = list_private_correction_rows(load_korrekturen())
if state["category"] == "correction":
return rows
return [r for r in rows if r.get("category") == state["category"]]
return list_public_entries(category=state["category"])
def _refresh_list():
nonlocal rows_cache
rows_cache = _load_rows()
listbox.delete(0, "end")
state["selected"] = None
detail_vars["falsch"].set("")
detail_vars["richtig"].set("")
detail_vars["note"].set("")
if state["scope"] == "private":
title_lbl.config(text="Eigene Bibliothek")
subtitle_lbl.config(
text="Diese Einträge werden für Ihre Transkriptionen, Korrekturen und Dokumente bevorzugt verwendet.")
else:
title_lbl.config(text="Öffentliche Bibliothek")
subtitle_lbl.config(
text="Von AzA kuratierte oder von Ärztinnen und Ärzten veröffentlichte Einträge. "
"Übernommene Einträge werden als eigene Kopie gespeichert.")
for row in _visible_rows():
listbox.insert("end", _format_row_label(row))
_rebuild_buttons()
def _on_select(_e=None):
sel = listbox.curselection()
if not sel:
return
visible = _visible_rows()
if sel[0] >= len(visible):
return
row = visible[sel[0]]
state["selected"] = row
detail_vars["falsch"].set(row.get("term") or "")
detail_vars["richtig"].set(row.get("preferred_spelling") or row.get("term") or "")
parts = []
if row.get("active_substance"):
parts.append(f"Wirkstoff: {row['active_substance']}")
if row.get("source"):
parts.append(f"Herkunft: {row['source']}")
if not row.get("active", True):
parts.append("Status: inaktiv")
detail_vars["note"].set(" · ".join(parts))
_rebuild_buttons()
listbox.bind("<<ListboxSelect>>", _on_select)
search_var.trace_add("write", lambda *_a: _refresh_list())
# ── Aktionen ───────────────────────────────────────────────────────────────
def _store_for_state() -> str:
sel = state.get("selected")
if sel and sel.get("store_category"):
return sel["store_category"]
return CATEGORY_TO_STORE.get(state["category"], "begriffe")
def _new_entry():
state["selected"] = None
detail_vars["falsch"].set("")
detail_vars["richtig"].set("")
detail_vars["note"].set("")
listbox.selection_clear(0, "end")
falsch_ent.focus_set()
_rebuild_buttons()
def _save_private():
f = detail_vars["falsch"].get().strip()
r = detail_vars["richtig"].get().strip()
if not f or not r:
messagebox.showinfo("Bibliothek", "Bitte Hörvariante und bevorzugte Schreibweise ausfüllen.", parent=win)
return
save_private_correction(_store_for_state(), f, r, active=True)
_refresh_list()
try:
app.set_status("Bibliothek: Eintrag gespeichert.")
except Exception:
pass
def _toggle_active():
row = state.get("selected")
if not row:
return
toggle_private_correction_active(row.get("store_category") or "begriffe", row.get("term") or "")
_refresh_list()
def _delete_private():
row = state.get("selected")
if not row:
return
if not messagebox.askyesno("Löschen?", "Eintrag wirklich löschen?", parent=win):
return
delete_private_correction(row.get("store_category") or "begriffe", row.get("term") or "")
_refresh_list()
def _adopt_public():
row = state.get("selected")
if not row:
messagebox.showinfo("Bibliothek", "Bitte zuerst einen öffentlichen Eintrag auswählen.", parent=win)
return
ok, msg = adopt_public_entry(row)
messagebox.showinfo("Bibliothek", msg, parent=win)
def _details_public():
row = state.get("selected")
if not row:
messagebox.showinfo("Bibliothek", "Bitte zuerst einen Eintrag auswählen.", parent=win)
return
lines = []
if row.get("brand_name"):
lines.append(f"Markenname: {row['brand_name']}")
if row.get("active_substance"):
lines.append(f"Wirkstoff: {row['active_substance']}")
if row.get("term"):
lines.append(f"Begriff: {row['term']}")
if row.get("preferred_spelling"):
lines.append(f"Bevorzugt: {row['preferred_spelling']}")
if row.get("market_region"):
lines.append(f"Region: {row['market_region']}")
if row.get("source"):
lines.append(f"Herkunft: {row['source']}")
messagebox.showinfo("Details", "\n".join(lines) or "Keine Details.", parent=win)
def _rebuild_buttons():
for w in btn_row.winfo_children():
w.destroy()
has_sel = state.get("selected") is not None
if state["scope"] == "private":
_btn(btn_row, "Neuer Eintrag", _new_entry).pack(side="left", padx=(0, 8))
_btn(btn_row, "Speichern", _save_private, primary=True).pack(side="left", padx=(0, 8))
if has_sel:
row = state["selected"]
if row.get("active", True):
_btn(btn_row, "Deaktivieren", _toggle_active).pack(side="left", padx=(0, 8))
else:
_btn(btn_row, "Aktivieren", _toggle_active).pack(side="left", padx=(0, 8))
_btn(btn_row, "Löschen", _delete_private, danger=True).pack(side="left", padx=(0, 8))
else:
_btn(btn_row, "Übernehmen", _adopt_public, primary=True).pack(side="left", padx=(0, 8))
_btn(btn_row, "Details", _details_public).pack(side="left", padx=(0, 8))
_btn(btn_row, "Aktualisieren", _refresh_list).pack(side="left", padx=(0, 8))
_btn(btn_row, "Schliessen", _close).pack(side="right")
def _set_scope(scope: str):
state["scope"] = scope
cats = PRIVATE_UI_CATEGORIES if scope == "private" else PUBLIC_UI_CATEGORIES
if state["category"] not in cats:
state["category"] = cats[0]
for k, p in scope_pills.items():
p.config(bg=_ACCENT if k == scope else _CARD_BG,
fg="#FFFFFF" if k == scope else _TEXT)
# Detailfelder bei öffentlicher Bibliothek nur lesend nutzen
editable = "normal" if scope == "private" else "disabled"
try:
falsch_ent.config(state=editable)
richtig_ent.config(state=editable)
except Exception:
pass
_rebuild_category_pills()
_refresh_list()
def _set_category(cat: str):
state["category"] = cat
_rebuild_category_pills()
_refresh_list()
scope_pills["private"] = _pill(scope_frame, "Eigene Bibliothek", active=True,
command=lambda: _set_scope("private"))
scope_pills["private"].pack(fill="x", pady=2)
scope_pills["public"] = _pill(scope_frame, "Öffentliche Bibliothek", active=False,
command=lambda: _set_scope("public"))
scope_pills["public"].pack(fill="x", pady=2)
_rebuild_category_pills()
_refresh_list()
_bring_to_front(win)