981 lines
38 KiB
Python
981 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
aza_doku_vorlagen.py — Doku-Prompt-Fenster (AzA-Stil)
|
||
|
||
Zentrale Prompt-Verwaltung fuer medizinische Dokumente (UI: Doku-Prompt).
|
||
Interne Datei/IDs bleiben aus Kompatibilitaetsgruenden unveraendert.
|
||
|
||
Kernprinzipien:
|
||
- AzA-Grundvorlage ist Systemvorlage und wird nicht direkt ueberschrieben.
|
||
- Benutzer darf den Inhalt im Editor bearbeiten und als neue Vorlage speichern.
|
||
- Immer genau eine aktive Vorlage pro Dokumenttyp.
|
||
- Alle Daten lokal in aza_document_templates.json.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import time
|
||
import tkinter as tk
|
||
from datetime import datetime, timezone
|
||
from tkinter import messagebox, simpledialog, ttk
|
||
from typing import Any
|
||
|
||
SCHEMA_VERSION = 2
|
||
MAX_PROMPT_CHARS = 32000
|
||
|
||
# ── AzA-Designkonstanten (identisch mit KI-Guthaben-Fenster) ──────────────────
|
||
_BG = "#E8F4FA"
|
||
_HDR_BG = "#1A4D6D"
|
||
_HDR_FG = "#FFFFFF"
|
||
_SUB_BG = "#D0E8F5"
|
||
_CARD_BG = "#FFFFFF"
|
||
_CARD_BD = "#C8D8E8"
|
||
_ACCENT = "#5B8DB3"
|
||
_TEXT = "#1A3D55"
|
||
_TEXT_SUB = "#607890"
|
||
_BTN_BLUE = "#5B8DB3"
|
||
_BTN_ACT = "#5B8DB3" # Einheitlich AzA-Blau (wie Speichern/Als neue Vorlage)
|
||
_BTN_RESET = "#C04040" # Rot nur fuer destruktive Aktion "Zurücksetzen"
|
||
_BTN_CLOSE = "#5B8DB3" # Einheitlich AzA-Blau
|
||
_FF = "Segoe UI"
|
||
|
||
# ── Dokumenttypen (Reihenfolge exakt laut Spezifikation) ─────────────────────
|
||
DOC_TYPES = [
|
||
("kg", "Krankengeschichte"),
|
||
("verlauf", "Verlauf"),
|
||
("brief", "Brief"),
|
||
("rezept", "Rezept"),
|
||
("op_bericht", "OP-Bericht"),
|
||
("kogu", "KOGU"),
|
||
("arztzeugnis", "Arztzeugnis"),
|
||
("eigenes_dokument", "Eigenes Dokument"),
|
||
]
|
||
|
||
DOC_TYPE_KEYS = [dt for dt, _ in DOC_TYPES]
|
||
|
||
# Status-Label pro Dokumenttyp (für dynamische "Erstelle …"-Statusmeldung).
|
||
DOC_TYPE_STATUS_LABELS = {
|
||
"kg": "Krankengeschichte",
|
||
"verlauf": "Verlauf",
|
||
"brief": "Brief",
|
||
"rezept": "Rezept",
|
||
"op_bericht": "OP-Bericht",
|
||
"kogu": "KOGU",
|
||
"arztzeugnis": "Arztzeugnis",
|
||
"eigenes_dokument": "Eigenes Dokument",
|
||
}
|
||
|
||
|
||
def doc_type_status_label(doc_type: str) -> str:
|
||
"""Reines Mapping doc_type → Anzeigename für Statusmeldungen."""
|
||
return DOC_TYPE_STATUS_LABELS.get(doc_type or "kg", "Krankengeschichte")
|
||
|
||
|
||
def resolve_doc_generation_route(doc_type: str) -> str:
|
||
"""Reines Routing: welcher Generierungspfad gehört zum Dokumenttyp.
|
||
|
||
Rückgabe:
|
||
"kg" → Krankengeschichte-Generator (summarize_text)
|
||
"form" → Formularpfad (Arztzeugnis)
|
||
"generate" → generate_document_to_main_field(doc_type)
|
||
"""
|
||
dt = doc_type or "kg"
|
||
if dt == "kg":
|
||
return "kg"
|
||
if dt == "arztzeugnis":
|
||
return "form"
|
||
if dt in ("verlauf", "brief", "rezept", "op_bericht", "kogu", "eigenes_dokument"):
|
||
return "generate"
|
||
return "kg"
|
||
|
||
DOC_TYPE_CREATE_LABELS = {
|
||
"kg": "KG erstellen",
|
||
"verlauf": "Verlauf erstellen",
|
||
"brief": "Brief erstellen",
|
||
"rezept": "Rezept erstellen",
|
||
"op_bericht": "OP-Bericht erstellen",
|
||
"kogu": "KOGU erstellen",
|
||
"arztzeugnis": "Arztzeugnis erstellen",
|
||
"eigenes_dokument": "Dokument erstellen",
|
||
}
|
||
|
||
DOC_TYPE_COPY_LABELS = {
|
||
"kg": "KG kopieren",
|
||
"verlauf": "Verlauf kopieren",
|
||
"brief": "Brief kopieren",
|
||
"rezept": "Rezept kopieren",
|
||
"op_bericht": "OP-Bericht kopieren",
|
||
"kogu": "KOGU kopieren",
|
||
"arztzeugnis": "Arztzeugnis kopieren",
|
||
"eigenes_dokument": "Dokument kopieren",
|
||
}
|
||
|
||
VERLAUF_DEFAULT_PROMPT = (
|
||
"Erstelle aus dem Transkript einen präzisen medizinischen Verlaufsbericht. "
|
||
"Beschreibe die Entwicklung seit der letzten Konsultation, den aktuellen subjektiven Zustand, "
|
||
"relevante neue oder persistierende Beschwerden, objektive Befunde, relevante Resultate, "
|
||
"die Wirkung und Verträglichkeit der bisherigen Therapie, erfolgte Therapieanpassungen "
|
||
"sowie das weitere Vorgehen. Schreibe chronologisch, sachlich und knapp. "
|
||
"Wiederhole die vollständige Anamnese nicht unnötig. "
|
||
"Verwende ausschliesslich Angaben aus dem Transkript und dem vorhandenen Kontext. "
|
||
"Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
|
||
"Gliedere sinnvoll in Verlauf, Befunde, Beurteilung und weiteres Vorgehen, "
|
||
"sofern genügend Informationen vorhanden sind."
|
||
)
|
||
|
||
EIGENES_DOKUMENT_DEFAULT_PROMPT = (
|
||
"Erstelle aus dem Transkript ein medizinisches Dokument gemäss den untenstehenden "
|
||
"Anweisungen. Verwende ausschliesslich Informationen aus dem Transkript und dem "
|
||
"vorhandenen Kontext. Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
|
||
"Schreibe sachlich, professionell und in Schweizer medizinischer Schreibweise."
|
||
)
|
||
|
||
# ── AzA-Grundvorlagen (verständlich formuliert, für Ärzte) ────────────────────
|
||
AZA_DEFAULT_TEMPLATES: dict[str, str] = {
|
||
"kg": (
|
||
"Reihenfolge: 1. Diagnose, 2. Anamnese, 3. Befund, 4. Therapie, 5. Procedere\n\n"
|
||
"Schreibregeln:\n"
|
||
"- Aus der Transkription eine klare medizinische Krankengeschichte erstellen.\n"
|
||
"- Diagnosen nummerieren (1. 2. 3. ...). KEIN Bulletpoint vor der Nummer.\n"
|
||
" Richtig: 1. Diagnose Falsch: • 1. Diagnose\n"
|
||
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt unter die\n"
|
||
" passende Diagnose schreiben, wenn eindeutig zuordenbar.\n"
|
||
"- Unklare Therapien separat unter 'Therapie' aufführen.\n"
|
||
"- Verlauf, Kontrollen und Wiedervorstellung unter 'Procedere', nicht unter 'Therapie'.\n"
|
||
"- Medikamente mit Wirkstoff in Klammern, wenn sicher bekannt:\n"
|
||
" Dermovate Creme (Clobetasol), Dafalgan (Paracetamol), Fucidin (Fusidinsäure).\n"
|
||
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
|
||
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden. Keine ICD-Codes erfinden.\n"
|
||
"- Keine Diagnosen, Therapien oder Daten erfinden.\n"
|
||
"- Keine genannten Diagnosen weglassen.\n"
|
||
"- Unterpunkte unter Diagnosen einrücken (Bindestrich).\n"
|
||
"- Schreibstil: medizinisch, professionell, kompakt.\n\n"
|
||
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter Diagnose,\n"
|
||
"Procedere getrennt) wird zusätzlich durch das Häkchen 'Diagnosen gliedern' gesteuert."
|
||
),
|
||
"brief": (
|
||
"Reihenfolge: 1. Diagnose, 2. Anlass, 3. Befunde, 4. Therapie, 5. Procedere/Empfehlung\n\n"
|
||
"Schreibregeln:\n"
|
||
"- Diagnosen nummerieren (1. 2. 3. ...).\n"
|
||
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt\n"
|
||
" unter die passende Diagnose schreiben.\n"
|
||
"- Verlauf, Kontrollen und Wiedervorstellung unter Procedere,\n"
|
||
" nicht unter Therapie.\n"
|
||
"- Medikamente mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
|
||
" Beispiel: Dermovate Creme (Clobetasol), Dafalgan (Paracetamol).\n"
|
||
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
|
||
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden.\n"
|
||
"- Nichts erfinden. Medizinisch, professionell und kompakt schreiben.\n\n"
|
||
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter\n"
|
||
"Diagnose, Procedere getrennt) wird zusätzlich durch das Häkchen\n"
|
||
"'Diagnosen gliedern' gesteuert."
|
||
),
|
||
"rezept": (
|
||
"Rezeptvorlage – Schreibregeln:\n\n"
|
||
"- Nur Medikamente aufführen, die im Transkript genannt wurden.\n"
|
||
"- Dosierung nur aus dem Transkript übernehmen, nichts erfinden.\n"
|
||
"- Markenname mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
|
||
" Beispiel: Dermovate Creme (Clobetasol) 2x täglich.\n"
|
||
"- Generikum bevorzugen, wenn nur der Wirkstoff genannt wurde.\n"
|
||
"- Keine ICD-Codes erfinden, wenn nicht vorhanden."
|
||
),
|
||
"op_bericht": (
|
||
"OP-Bericht / Eingriffsbericht – Reihenfolge:\n\n"
|
||
"1. Diagnose (mit ICD-Code, falls vorhanden)\n"
|
||
"2. Indikation\n"
|
||
"3. Eingriff / Prozedur\n"
|
||
"4. Befund / Histologie\n"
|
||
"5. Verlauf / Procedere\n\n"
|
||
"Schreibregeln:\n"
|
||
"- 'Status nach' als Standard, nicht 'Zustand nach'.\n"
|
||
"- Keine Befunde oder Histologien erfinden.\n"
|
||
"- ICD-Codes nur übernehmen, wenn im Transkript genannt.\n"
|
||
"- Medizinisch korrekter Fachstil."
|
||
),
|
||
"kogu": (
|
||
"Kostengutsprache – Reihenfolge:\n\n"
|
||
"1. Patient / Versicherung\n"
|
||
"2. Diagnose (ICD-Code falls vorhanden)\n"
|
||
"3. Klinische Begründung / Notwendigkeit\n"
|
||
"4. Beantragte Massnahme / Behandlung\n"
|
||
"5. Kosten / Tarifpositionen (nur wenn im Transkript genannt)\n"
|
||
"6. Datum / Unterschrift\n\n"
|
||
"Schreibregeln:\n"
|
||
"- Kosten und Tarifpositionen nur aufnehmen, wenn genannt.\n"
|
||
"- Medizinische Begründung sachlich und klinisch formulieren.\n"
|
||
"- Keine Diagnosen oder Befunde erfinden."
|
||
),
|
||
"arztzeugnis": (
|
||
"Arztzeugnis – Reihenfolge:\n\n"
|
||
"1. Angaben Patient/in\n"
|
||
"2. Diagnose (ICD-Code falls vorhanden)\n"
|
||
"3. Behandlungszeitraum\n"
|
||
"4. Arbeitsfähigkeit / Arbeitsunfähigkeit\n"
|
||
"5. Besondere Anmerkungen (nur aus Transkript)\n"
|
||
"6. Datum, Arzt/Ärztin\n\n"
|
||
"Schreibregeln:\n"
|
||
"- Zeiträume nur aus Transkript übernehmen, nichts erfinden.\n"
|
||
"- Diagnose medizinisch korrekt, ICD-Code wenn vorhanden.\n"
|
||
"- Sachlicher, formeller Schreibstil."
|
||
),
|
||
"verlauf": VERLAUF_DEFAULT_PROMPT,
|
||
"eigenes_dokument": EIGENES_DOKUMENT_DEFAULT_PROMPT,
|
||
}
|
||
|
||
|
||
# ── Persistenz ────────────────────────────────────────────────────────────────
|
||
def _templates_json_path() -> str:
|
||
try:
|
||
from aza_config import get_writable_data_dir
|
||
return os.path.join(get_writable_data_dir(), "aza_document_templates.json")
|
||
except Exception:
|
||
return os.path.join(os.path.expanduser("~"), "AppData", "Roaming",
|
||
"AZA Desktop", "aza_document_templates.json")
|
||
|
||
|
||
def _utc_now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def sanitize_prompt_text(text: str) -> str:
|
||
"""Plaintext only — keine HTML/Script-Injection."""
|
||
if not text:
|
||
return ""
|
||
s = str(text)
|
||
s = re.sub(r"(?is)<script[^>]*>.*?</script>", "", s)
|
||
s = re.sub(r"(?is)<style[^>]*>.*?</style>", "", s)
|
||
s = re.sub(r"<[^>]+>", "", s)
|
||
s = re.sub(r"(?i)javascript\s*:", "", s)
|
||
s = re.sub(r"(?i)data\s*:", "", s)
|
||
s = s.replace("\x00", "")
|
||
if len(s) > MAX_PROMPT_CHARS:
|
||
s = s[:MAX_PROMPT_CHARS]
|
||
return s.strip()
|
||
|
||
|
||
def _new_template_entry(doc_type: str, *, now: str | None = None) -> dict[str, Any]:
|
||
ts = now or _utc_now()
|
||
return {
|
||
"id": "aza_default",
|
||
"name": "AzA-Grundvorlage",
|
||
"is_system": True,
|
||
"content": AZA_DEFAULT_TEMPLATES.get(doc_type, ""),
|
||
"description": "",
|
||
"created_at": ts,
|
||
"updated_at": ts,
|
||
"revision": 1,
|
||
"user_id": "",
|
||
"practice_id": "",
|
||
"server_id": "",
|
||
"published": False,
|
||
"sync_updated_at": "",
|
||
"source_author": "",
|
||
"source_server_id": "",
|
||
}
|
||
|
||
|
||
def _ensure_template_fields(t: dict[str, Any]) -> dict[str, Any]:
|
||
if not isinstance(t, dict):
|
||
return _new_template_entry("kg")
|
||
t.setdefault("description", "")
|
||
t.setdefault("revision", 1)
|
||
t.setdefault("user_id", "")
|
||
t.setdefault("practice_id", "")
|
||
t.setdefault("server_id", "")
|
||
t.setdefault("published", False)
|
||
t.setdefault("sync_updated_at", "")
|
||
t.setdefault("source_author", "")
|
||
t.setdefault("source_server_id", "")
|
||
if "content" in t:
|
||
t["content"] = sanitize_prompt_text(t.get("content") or "")
|
||
return t
|
||
|
||
|
||
def _default_structure() -> dict[str, Any]:
|
||
now = _utc_now()
|
||
return {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"active": {dt: "aza_default" for dt, _ in DOC_TYPES},
|
||
"templates": {
|
||
dt: [_new_template_entry(dt, now=now)]
|
||
for dt, _ in DOC_TYPES
|
||
},
|
||
"sync_meta": {"last_sync_at": "", "conflicts": []},
|
||
}
|
||
|
||
|
||
def _backup_json_before_migration(path: str) -> None:
|
||
if not os.path.isfile(path):
|
||
return
|
||
try:
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
shutil.copy2(path, path + f".pre_migrate_{stamp}.bak")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _migrate_data(d: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||
"""Migration schema v1→v2: fehlende Typen ergänzen, Metadaten anreichern."""
|
||
changed = False
|
||
now = _utc_now()
|
||
sv = int(d.get("schema_version") or 1)
|
||
|
||
for dt, _ in DOC_TYPES:
|
||
if dt not in d.get("templates", {}):
|
||
d.setdefault("templates", {})[dt] = [_new_template_entry(dt, now=now)]
|
||
changed = True
|
||
if dt not in d.get("active", {}):
|
||
d.setdefault("active", {})[dt] = "aza_default"
|
||
changed = True
|
||
tpls = d.get("templates", {}).get(dt, [])
|
||
for i, t in enumerate(tpls):
|
||
if isinstance(t, dict):
|
||
before = json.dumps(t, sort_keys=True)
|
||
tpls[i] = _ensure_template_fields(t)
|
||
if json.dumps(tpls[i], sort_keys=True) != before:
|
||
changed = True
|
||
|
||
if "sync_meta" not in d:
|
||
d["sync_meta"] = {"last_sync_at": "", "conflicts": []}
|
||
changed = True
|
||
|
||
if sv < SCHEMA_VERSION:
|
||
d["schema_version"] = SCHEMA_VERSION
|
||
changed = True
|
||
|
||
return d, changed
|
||
|
||
|
||
def _load() -> dict[str, Any]:
|
||
path = _templates_json_path()
|
||
if os.path.isfile(path):
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
d = json.load(f)
|
||
if isinstance(d, dict) and d.get("schema_version") in (1, 2):
|
||
d, changed = _migrate_data(d)
|
||
if changed:
|
||
try:
|
||
_backup_json_before_migration(path)
|
||
_save(d)
|
||
except Exception:
|
||
pass
|
||
return d
|
||
except Exception:
|
||
pass
|
||
return _default_structure()
|
||
|
||
|
||
def _load_all_templates() -> dict[str, Any]:
|
||
"""Alias fuer externe Module (z. B. aza_text_windows_mixin)."""
|
||
return _load()
|
||
|
||
|
||
def _save(data: dict[str, Any]) -> None:
|
||
path = _templates_json_path()
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def get_active_template_content(doc_type: str) -> str:
|
||
try:
|
||
data = _load()
|
||
aid = data.get("active", {}).get(doc_type, "aza_default")
|
||
for t in data.get("templates", {}).get(doc_type, []):
|
||
if isinstance(t, dict) and t.get("id") == aid:
|
||
return sanitize_prompt_text(t.get("content") or "")
|
||
except Exception:
|
||
pass
|
||
return AZA_DEFAULT_TEMPLATES.get(doc_type, "")
|
||
|
||
|
||
def get_active_template_id(doc_type: str) -> str:
|
||
try:
|
||
data = _load()
|
||
return str(data.get("active", {}).get(doc_type, "aza_default"))
|
||
except Exception:
|
||
return "aza_default"
|
||
|
||
|
||
def doc_type_label(doc_type: str) -> str:
|
||
for dt, label in DOC_TYPES:
|
||
if dt == doc_type:
|
||
return label
|
||
return doc_type
|
||
|
||
|
||
def copy_public_template_as_own(
|
||
doc_type: str,
|
||
*,
|
||
content: str,
|
||
title: str,
|
||
source_author: str = "",
|
||
source_server_id: str = "",
|
||
) -> str | None:
|
||
"""Übernimmt eine öffentliche Vorlage als neue eigene Kopie (neue ID)."""
|
||
data = _load()
|
||
name = (title or "Übernommene Vorlage").strip()
|
||
new_id = f"user_{int(time.time())}_{doc_type}"
|
||
now = _utc_now()
|
||
new_tpl = {
|
||
"id": new_id,
|
||
"name": name,
|
||
"is_system": False,
|
||
"content": sanitize_prompt_text(content),
|
||
"description": "",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"revision": 1,
|
||
"user_id": "",
|
||
"practice_id": "",
|
||
"server_id": "",
|
||
"published": False,
|
||
"sync_updated_at": "",
|
||
"source_author": (source_author or "").strip(),
|
||
"source_server_id": (source_server_id or "").strip(),
|
||
}
|
||
data.setdefault("templates", {}).setdefault(doc_type, []).append(new_tpl)
|
||
_save(data)
|
||
return new_id
|
||
|
||
|
||
# ── Sync / Veröffentlichung (Client, kein Deploy in diesem Block) ─────────────
|
||
|
||
def _app_user_practice_ids(app: Any) -> tuple[str, str]:
|
||
user_id = ""
|
||
practice_id = ""
|
||
try:
|
||
practice_id = (app.get_practice_id() or "").strip()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
user_id = (getattr(app, "_empfang_self_user_id", lambda: "")() or "").strip()
|
||
except Exception:
|
||
user_id = ""
|
||
return user_id, practice_id
|
||
|
||
|
||
def sync_private_doku_prompts(app: Any) -> bool:
|
||
"""Synchronisiert private Benutzer-Prompts mit dem Server (falls erreichbar)."""
|
||
try:
|
||
from aza_doku_prompt_sync import sync_doku_prompts_with_server
|
||
return sync_doku_prompts_with_server(app)
|
||
except ImportError:
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def publish_template_to_server(app: Any, doc_type: str, tpl: dict[str, Any],
|
||
title: str, description: str = "") -> tuple[bool, str]:
|
||
"""Veröffentlicht aktuelle Vorlage auf dem Server (nur wenn angemeldet)."""
|
||
try:
|
||
from aza_doku_prompt_sync import publish_doku_prompt
|
||
return publish_doku_prompt(app, doc_type, tpl, title, description)
|
||
except ImportError:
|
||
return False, "Sync-Modul nicht verfügbar."
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
def fetch_public_prompt_library(app: Any) -> list[dict[str, Any]]:
|
||
try:
|
||
from aza_doku_prompt_sync import fetch_public_doku_prompts
|
||
return fetch_public_doku_prompts(app)
|
||
except ImportError:
|
||
return []
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
# ── Fenster ───────────────────────────────────────────────────────────────────
|
||
def open_doku_vorlage_fenster(app: Any) -> None:
|
||
"""Oeffnet das Doku-Prompt-Fenster. Singleton."""
|
||
existing = getattr(app, "_doku_vorlage_win", None)
|
||
if existing is not None:
|
||
try:
|
||
if existing.winfo_exists():
|
||
existing.lift(); existing.focus_force(); return
|
||
except Exception:
|
||
pass
|
||
|
||
W, H = 820, 600
|
||
|
||
win = tk.Toplevel(app)
|
||
app._doku_vorlage_win = win
|
||
win.title("Doku-Prompt")
|
||
win.configure(bg=_BG)
|
||
win.resizable(True, True)
|
||
win.minsize(700, 520)
|
||
|
||
try:
|
||
win.transient(app)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
win.update_idletasks()
|
||
sw = win.winfo_screenwidth()
|
||
x = max(0, (sw - W) // 2)
|
||
win.geometry(f"{W}x{H}+{x}+60")
|
||
except Exception:
|
||
win.geometry(f"{W}x{H}")
|
||
|
||
try:
|
||
win.lift(); win.focus_force()
|
||
except Exception:
|
||
pass
|
||
|
||
if hasattr(app, "_register_window"):
|
||
try: app._register_window(win)
|
||
except Exception: pass
|
||
|
||
try:
|
||
import threading
|
||
threading.Thread(target=lambda: sync_private_doku_prompts(app), daemon=True).start()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Header ────────────────────────────────────────────────────────────────
|
||
hdr = tk.Frame(win, bg=_HDR_BG)
|
||
hdr.pack(fill="x")
|
||
tk.Label(hdr, text="📄 Doku-Prompt", font=(_FF, 13, "bold"),
|
||
bg=_HDR_BG, fg=_HDR_FG, padx=18, pady=10).pack(side="left")
|
||
close_x = tk.Label(hdr, text="✕", font=(_FF, 11), bg=_HDR_BG, fg="#A0C4D8",
|
||
cursor="hand2", padx=14)
|
||
close_x.pack(side="right")
|
||
|
||
sub = tk.Frame(win, bg=_SUB_BG)
|
||
sub.pack(fill="x")
|
||
tk.Label(sub,
|
||
text="Prompts für medizinische Dokumente anpassen. "
|
||
"Diagnose-/Therapie-Gliederung wird über das Häkchen 'Diagnosen gliedern' gesteuert.",
|
||
font=(_FF, 8), bg=_SUB_BG, fg=_TEXT_SUB,
|
||
padx=18, pady=5, wraplength=780, justify="left").pack(anchor="w")
|
||
|
||
# ── Hauptbereich ──────────────────────────────────────────────────────────
|
||
body = tk.Frame(win, bg=_BG)
|
||
body.pack(fill="both", expand=True, padx=10, pady=8)
|
||
body.columnconfigure(1, weight=1)
|
||
body.rowconfigure(0, weight=1)
|
||
|
||
# Linke Spalte — Dokumenttypen
|
||
left = tk.Frame(body, bg=_CARD_BG, bd=0,
|
||
highlightbackground=_CARD_BD, highlightthickness=1, width=140)
|
||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||
left.grid_propagate(False)
|
||
|
||
tk.Label(left, text="Dokumenttyp", font=(_FF, 9, "bold"),
|
||
bg="#E0EEF7", fg=_TEXT, pady=6).pack(fill="x")
|
||
|
||
# Rechte Karte — Bearbeitungsfläche
|
||
right = tk.Frame(body, bg=_CARD_BG, bd=0,
|
||
highlightbackground=_CARD_BD, highlightthickness=1)
|
||
right.grid(row=0, column=1, sticky="nsew")
|
||
right.rowconfigure(2, weight=1)
|
||
right.columnconfigure(0, weight=1)
|
||
|
||
# ── State-Variablen ───────────────────────────────────────────────────────
|
||
_data: list[dict] = [_load()]
|
||
_cur_dt: list[str] = ["kg"]
|
||
_cur_tpl: list[dict] = [{}]
|
||
_dirty: list[bool] = [False]
|
||
|
||
combo_var = [None]
|
||
combo_widget = [None]
|
||
txt_content = [None]
|
||
lbl_doctype = [None]
|
||
lbl_active_info = [None]
|
||
lbl_mode = [None]
|
||
btn_save = [None]
|
||
btn_activate = [None]
|
||
library_items: list[list[dict]] = [[]]
|
||
library_listbox: list[tk.Listbox | None] = [None]
|
||
|
||
def _tpls(dt): return _data[0].get("templates", {}).get(dt, [])
|
||
def _aid(dt): return _data[0].get("active", {}).get(dt, "aza_default")
|
||
def _by_id(dt, tid):
|
||
for t in _tpls(dt):
|
||
if isinstance(t, dict) and t.get("id") == tid: return t
|
||
return None
|
||
def _dname(t):
|
||
n = (t.get("name") or "Unbenannt").strip()
|
||
return f"★ {n}" if t.get("is_system") else n
|
||
|
||
def _refresh(dt: str, tpl_id: str | None = None):
|
||
templates = _tpls(dt)
|
||
active_id = _aid(dt)
|
||
target_id = tpl_id or active_id
|
||
tpl = _by_id(dt, target_id)
|
||
if tpl is None and templates:
|
||
tpl = templates[0]
|
||
if tpl is None:
|
||
tpl = {"id": "aza_default", "name": "AzA-Grundvorlage",
|
||
"is_system": True, "content": AZA_DEFAULT_TEMPLATES.get(dt, "")}
|
||
_cur_tpl[0] = tpl
|
||
|
||
# Combo
|
||
names = [_dname(t) for t in templates if isinstance(t, dict)]
|
||
if combo_widget[0]:
|
||
combo_widget[0]["values"] = names
|
||
combo_var[0].set(_dname(tpl))
|
||
|
||
# Label Dokumenttyp
|
||
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
|
||
if lbl_doctype[0]:
|
||
lbl_doctype[0].config(text=dlabel)
|
||
|
||
# Textfeld — IMMER beschreibbar, unabhaengig von Systemvorlage
|
||
if txt_content[0]:
|
||
txt_content[0].config(state="normal")
|
||
txt_content[0].delete("1.0", "end")
|
||
txt_content[0].insert("1.0", tpl.get("content") or "")
|
||
txt_content[0].config(state="normal", bg="#FFFFFF", fg=_TEXT)
|
||
|
||
# Aktive-Info
|
||
at = _by_id(dt, active_id)
|
||
aname = _dname(at) if at else active_id
|
||
if lbl_active_info[0]:
|
||
lbl_active_info[0].config(text=f"Aktive Vorlage: {aname}")
|
||
|
||
is_system = bool(tpl.get("is_system"))
|
||
is_active = (tpl.get("id") == active_id)
|
||
|
||
# Modus-Hinweis
|
||
if lbl_mode[0]:
|
||
if is_system:
|
||
lbl_mode[0].config(
|
||
text="Systemvorlage – 'Speichern' nicht direkt möglich → 'Als neue Vorlage speichern'",
|
||
fg="#B06000")
|
||
else:
|
||
lbl_mode[0].config(
|
||
text="Benutzervorlage – direkt bearbeitbar und speicherbar.",
|
||
fg="#2A7A4A")
|
||
|
||
# Buttons
|
||
if btn_activate[0]:
|
||
btn_activate[0].config(
|
||
text="Aktiv" if is_active else "Als aktiv setzen",
|
||
bg="#8AAFC8" if is_active else _BTN_ACT,
|
||
state="disabled" if is_active else "normal",
|
||
)
|
||
if btn_save[0]:
|
||
# Speichern bei Systemvorlage deaktivieren — klare Führung
|
||
btn_save[0].config(
|
||
state="disabled" if is_system else "normal",
|
||
bg="#8AAFC8" if is_system else _BTN_BLUE,
|
||
)
|
||
_dirty[0] = False
|
||
|
||
def _on_combo(evt=None):
|
||
dt = _cur_dt[0]
|
||
sel = combo_var[0].get() if combo_var[0] else ""
|
||
for t in _tpls(dt):
|
||
if isinstance(t, dict) and _dname(t) == sel:
|
||
_refresh(dt, t.get("id")); return
|
||
|
||
def _do_save():
|
||
tpl = _cur_tpl[0]
|
||
if tpl.get("is_system"):
|
||
messagebox.showinfo(
|
||
"Systemvorlage",
|
||
"Die AzA-Grundvorlage kann nicht direkt überschrieben werden.\n\n"
|
||
"Bitte verwenden Sie 'Als neue Vorlage speichern', um eine\n"
|
||
"eigene Variante zu erstellen.",
|
||
parent=win)
|
||
return
|
||
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
|
||
tpl["content"] = content
|
||
tpl["updated_at"] = _utc_now()
|
||
tpl["revision"] = int(tpl.get("revision") or 0) + 1
|
||
try:
|
||
_save(_data[0])
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=win); return
|
||
_dirty[0] = False
|
||
messagebox.showinfo("Gespeichert", "Vorlage gespeichert.", parent=win)
|
||
|
||
def _do_save_new():
|
||
dt = _cur_dt[0]
|
||
name = simpledialog.askstring("Neue Vorlage", "Name der neuen Vorlage:", parent=win)
|
||
if not name or not name.strip(): return
|
||
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
|
||
new_id = f"user_{int(time.time())}_{dt}"
|
||
uid, pid = _app_user_practice_ids(app)
|
||
new_tpl = {
|
||
"id": new_id, "name": name.strip(), "is_system": False,
|
||
"content": content, "description": "",
|
||
"created_at": _utc_now(), "updated_at": _utc_now(),
|
||
"revision": 1, "user_id": uid, "practice_id": pid,
|
||
"server_id": "", "published": False, "sync_updated_at": "",
|
||
"source_author": "", "source_server_id": "",
|
||
}
|
||
_data[0].setdefault("templates", {}).setdefault(dt, []).append(new_tpl)
|
||
activate = messagebox.askyesno("Aktivieren?",
|
||
f"Neue Vorlage '{name.strip()}' gespeichert.\nJetzt aktivieren?", parent=win)
|
||
if activate:
|
||
_data[0].setdefault("active", {})[dt] = new_id
|
||
try:
|
||
_save(_data[0])
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=win); return
|
||
_refresh(dt, new_id if activate else _aid(dt))
|
||
|
||
def _do_activate():
|
||
dt = _cur_dt[0]; tpl = _cur_tpl[0]; tid = tpl.get("id", "")
|
||
if not tid: return
|
||
_data[0].setdefault("active", {})[dt] = tid
|
||
try: _save(_data[0])
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=win); return
|
||
_refresh(dt, tid)
|
||
|
||
def _do_reset():
|
||
dt = _cur_dt[0]
|
||
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
|
||
if not messagebox.askyesno(
|
||
"Zurücksetzen?",
|
||
f"'{dlabel}' auf AzA-Grundvorlage zurücksetzen?\n\n"
|
||
"Die aktive Vorlage wird auf AzA-Grundvorlage gesetzt.\n"
|
||
"Ihre eigenen Varianten werden NICHT gelöscht.", parent=win):
|
||
return
|
||
templates = _tpls(dt)
|
||
has_default = any(isinstance(t, dict) and t.get("id") == "aza_default"
|
||
for t in templates)
|
||
if not has_default:
|
||
now = _utc_now()
|
||
_data[0].setdefault("templates", {}).setdefault(dt, []).insert(0, {
|
||
"id": "aza_default", "name": "AzA-Grundvorlage", "is_system": True,
|
||
"content": AZA_DEFAULT_TEMPLATES.get(dt, ""),
|
||
"created_at": now, "updated_at": now})
|
||
_data[0].setdefault("active", {})[dt] = "aza_default"
|
||
try: _save(_data[0])
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=win); return
|
||
_refresh(dt, "aza_default")
|
||
|
||
def _do_delete():
|
||
dt = _cur_dt[0]
|
||
tpl = _cur_tpl[0]
|
||
if tpl.get("is_system"):
|
||
messagebox.showinfo("Systemvorlage", "AzA-Grundvorlage kann nicht gelöscht werden.", parent=win)
|
||
return
|
||
tid = tpl.get("id", "")
|
||
if not tid:
|
||
return
|
||
name = (tpl.get("name") or "Unbenannt").strip()
|
||
if not messagebox.askyesno("Löschen?", f"Vorlage '{name}' wirklich löschen?", parent=win):
|
||
return
|
||
templates = _tpls(dt)
|
||
_data[0]["templates"][dt] = [t for t in templates if not (isinstance(t, dict) and t.get("id") == tid)]
|
||
if _aid(dt) == tid:
|
||
_data[0].setdefault("active", {})[dt] = "aza_default"
|
||
try:
|
||
_save(_data[0])
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=win)
|
||
return
|
||
_refresh(dt, _aid(dt))
|
||
|
||
def _do_publish():
|
||
dt = _cur_dt[0]
|
||
tpl = dict(_cur_tpl[0])
|
||
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
|
||
if not content:
|
||
messagebox.showinfo("Hinweis", "Bitte zuerst einen Prompt-Inhalt eingeben.", parent=win)
|
||
return
|
||
tpl["content"] = content
|
||
default_title = (tpl.get("name") or doc_type_label(dt)).strip()
|
||
title = simpledialog.askstring("Titel", "Titel für Veröffentlichung:", initialvalue=default_title, parent=win)
|
||
if not title or not title.strip():
|
||
return
|
||
desc = simpledialog.askstring("Beschreibung", "Kurze Beschreibung (optional):", parent=win) or ""
|
||
preview = content[:600] + ("…" if len(content) > 600 else "")
|
||
if not messagebox.askyesno(
|
||
"Veröffentlichen?",
|
||
f"Dokumenttyp: {doc_type_label(dt)}\nTitel: {title.strip()}\n\nVorschau:\n{preview}\n\n"
|
||
"Bitte prüfen Sie, dass diese Vorlage keine Patientendaten oder "
|
||
"vertraulichen Praxisdaten enthält.\n\n"
|
||
"Für andere angemeldete Ärzte veröffentlichen?",
|
||
parent=win,
|
||
):
|
||
return
|
||
ok, msg = publish_template_to_server(app, dt, tpl, title.strip(), desc.strip())
|
||
if ok:
|
||
tpl["published"] = True
|
||
tpl["updated_at"] = _utc_now()
|
||
_save(_data[0])
|
||
messagebox.showinfo("Veröffentlicht", "Prompt wurde veröffentlicht.", parent=win)
|
||
_refresh_library()
|
||
else:
|
||
messagebox.showwarning("Veröffentlichung", msg or "Server nicht erreichbar — lokal gespeichert.", parent=win)
|
||
|
||
def _refresh_library():
|
||
if not library_listbox[0]:
|
||
return
|
||
library_listbox[0].delete(0, "end")
|
||
library_items[0].clear()
|
||
for item in fetch_public_prompt_library(app):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
dt = item.get("doc_type") or ""
|
||
title = item.get("title") or "Ohne Titel"
|
||
author = item.get("author_display") or item.get("author") or "Unbekannt"
|
||
label = f"[{doc_type_label(dt)}] {title} — {author}"
|
||
library_listbox[0].insert("end", label)
|
||
library_items[0].append(item)
|
||
|
||
def _adopt_library_item(as_new: bool = True):
|
||
sel = library_listbox[0].curselection() if library_listbox[0] else ()
|
||
if not sel:
|
||
messagebox.showinfo("Hinweis", "Bitte eine Vorlage aus der Bibliothek wählen.", parent=win)
|
||
return
|
||
item = library_items[0][sel[0]]
|
||
dt = item.get("doc_type") or _cur_dt[0]
|
||
content = item.get("content") or ""
|
||
title = item.get("title") or "Übernommene Vorlage"
|
||
author = item.get("author_display") or item.get("author") or ""
|
||
sid = item.get("id") or item.get("server_id") or ""
|
||
new_id = copy_public_template_as_own(
|
||
dt, content=content, title=title,
|
||
source_author=author, source_server_id=sid,
|
||
)
|
||
if new_id:
|
||
_sel_dt(dt)
|
||
_refresh(dt, new_id)
|
||
messagebox.showinfo("Übernommen", "Vorlage als eigene Kopie gespeichert.", parent=win)
|
||
|
||
def _close():
|
||
try: app._doku_vorlage_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)
|
||
|
||
# ── Linke Buttons (Dokumenttypen) ─────────────────────────────────────────
|
||
_dt_btns: dict[str, tk.Label] = {}
|
||
|
||
def _sel_dt(dt: str):
|
||
_cur_dt[0] = dt
|
||
for k, b in _dt_btns.items():
|
||
b.config(bg=_ACCENT if k == dt else _CARD_BG,
|
||
fg="#FFFFFF" if k == dt else _TEXT)
|
||
_refresh(dt)
|
||
|
||
for dt_key, dt_label in DOC_TYPES:
|
||
b = tk.Label(left, text=dt_label, font=(_FF, 9),
|
||
bg=_CARD_BG, fg=_TEXT, cursor="hand2",
|
||
pady=9, anchor="w", padx=12)
|
||
b.pack(fill="x")
|
||
b.bind("<Button-1>", lambda e, dk=dt_key: _sel_dt(dk))
|
||
_dt_btns[dt_key] = b
|
||
|
||
# ── Rechte Karte ──────────────────────────────────────────────────────────
|
||
# Zeile 0: Header (Dokumenttyp + aktive Vorlage)
|
||
top_bar = tk.Frame(right, bg="#E0EEF7")
|
||
top_bar.grid(row=0, column=0, sticky="ew")
|
||
|
||
lbl_doctype[0] = tk.Label(top_bar, text="Krankengeschichte", font=(_FF, 10, "bold"),
|
||
bg="#E0EEF7", fg=_TEXT, padx=12, pady=6)
|
||
lbl_doctype[0].pack(side="left")
|
||
lbl_active_info[0] = tk.Label(top_bar, text="Aktive Vorlage: AzA-Grundvorlage",
|
||
font=(_FF, 8), bg="#E0EEF7", fg=_TEXT_SUB, padx=8)
|
||
lbl_active_info[0].pack(side="left")
|
||
|
||
# Zeile 1: Vorlage-Auswahl + Modus-Hinweis
|
||
sel_bar = tk.Frame(right, bg=_CARD_BG)
|
||
sel_bar.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 2))
|
||
|
||
tk.Label(sel_bar, text="Vorlage:", font=(_FF, 8),
|
||
bg=_CARD_BG, fg=_TEXT_SUB).pack(side="left")
|
||
combo_var[0] = tk.StringVar()
|
||
combo_widget[0] = ttk.Combobox(sel_bar, textvariable=combo_var[0],
|
||
state="readonly", width=32, font=(_FF, 9))
|
||
combo_widget[0].pack(side="left", padx=(4, 12))
|
||
combo_widget[0].bind("<<ComboboxSelected>>", _on_combo)
|
||
|
||
lbl_mode[0] = tk.Label(sel_bar, text="", font=(_FF, 7),
|
||
bg=_CARD_BG, fg="#B06000", padx=4)
|
||
lbl_mode[0].pack(side="left")
|
||
|
||
# Zeile 2: Textfeld (expandierend)
|
||
txt_frame = tk.Frame(right, bg=_CARD_BG)
|
||
txt_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(2, 4))
|
||
txt_frame.rowconfigure(0, weight=1)
|
||
txt_frame.columnconfigure(0, weight=1)
|
||
|
||
vsb = tk.Scrollbar(txt_frame, orient="vertical")
|
||
vsb.grid(row=0, column=1, sticky="ns")
|
||
txt_content[0] = tk.Text(txt_frame, font=(_FF, 9),
|
||
bg="#FFFFFF", fg=_TEXT,
|
||
relief="flat", bd=0, wrap="word",
|
||
yscrollcommand=vsb.set,
|
||
highlightbackground=_CARD_BD, highlightthickness=1,
|
||
padx=8, pady=8)
|
||
txt_content[0].grid(row=0, column=0, sticky="nsew")
|
||
vsb.config(command=txt_content[0].yview)
|
||
txt_content[0].bind("<KeyRelease>", lambda e: _dirty.__setitem__(0, True))
|
||
|
||
# Zeile 3: Button-Leiste (feste Höhe, nie abgeschnitten)
|
||
btn_row = tk.Frame(right, bg="#E8F0F5", pady=8)
|
||
btn_row.grid(row=3, column=0, sticky="ew", padx=0)
|
||
|
||
def _mk(parent, text, cmd, bg, fg="#FFFFFF", width=16):
|
||
return tk.Button(parent, text=text, command=cmd,
|
||
font=(_FF, 8, "bold"), bg=bg, fg=fg,
|
||
relief="flat", bd=0, padx=10, pady=5,
|
||
cursor="hand2", width=width,
|
||
activebackground=bg)
|
||
|
||
btn_save[0] = _mk(btn_row, "Speichern", _do_save, _BTN_BLUE, width=12)
|
||
btn_save[0].pack(side="left", padx=(10, 4))
|
||
_mk(btn_row, "Als neue Vorlage", _do_save_new, _BTN_BLUE, width=16).pack(side="left", padx=4)
|
||
btn_activate[0] = _mk(btn_row, "Als aktiv setzen", _do_activate, _BTN_ACT, width=15)
|
||
btn_activate[0].pack(side="left", padx=4)
|
||
_mk(btn_row, "Löschen", _do_delete, _BTN_RESET, width=10).pack(side="left", padx=4)
|
||
btn_publish = _mk(
|
||
btn_row, "Für Ärzte veröffentlichen", _do_publish, _BTN_BLUE, width=24,
|
||
)
|
||
btn_publish.pack(side="left", padx=4)
|
||
try:
|
||
from aza_ui_helpers import add_tooltip
|
||
add_tooltip(btn_publish, "Aktuellen Prompt für andere angemeldete Ärzte freigeben.")
|
||
except Exception:
|
||
pass
|
||
_mk(btn_row, "Schließen", _close, _BTN_CLOSE, width=10).pack(side="right", padx=(4, 10))
|
||
_mk(btn_row, "↩ Auf Grundvorlage", _do_reset, _BTN_RESET, width=18).pack(side="right", padx=4)
|
||
|
||
# Öffentliche Prompt-Bibliothek
|
||
lib_frame = tk.Frame(win, bg=_BG)
|
||
lib_frame.pack(fill="x", padx=10, pady=(0, 8))
|
||
tk.Label(lib_frame, text="Öffentliche Prompt-Bibliothek", font=(_FF, 9, "bold"),
|
||
bg=_BG, fg=_TEXT).pack(anchor="w")
|
||
lib_inner = tk.Frame(lib_frame, bg=_CARD_BG,
|
||
highlightbackground=_CARD_BD, highlightthickness=1)
|
||
lib_inner.pack(fill="x", pady=(4, 0))
|
||
lb = tk.Listbox(lib_inner, font=(_FF, 8), height=4, bg="#FFFFFF", fg=_TEXT,
|
||
selectbackground=_ACCENT, activestyle="none")
|
||
lb.pack(side="left", fill="both", expand=True, padx=6, pady=6)
|
||
library_listbox[0] = lb
|
||
lib_btns = tk.Frame(lib_inner, bg=_CARD_BG)
|
||
lib_btns.pack(side="right", fill="y", padx=6, pady=6)
|
||
tk.Button(lib_btns, text="Aktualisieren", command=_refresh_library,
|
||
font=(_FF, 8), bg=_BTN_BLUE, fg="#FFFFFF", relief="flat",
|
||
cursor="hand2", padx=8, pady=4).pack(fill="x", pady=(0, 4))
|
||
tk.Button(lib_btns, text="Als eigene Vorlage\nübernehmen", command=lambda: _adopt_library_item(True),
|
||
font=(_FF, 8), bg=_BTN_BLUE, fg="#FFFFFF", relief="flat",
|
||
cursor="hand2", padx=8, pady=4).pack(fill="x")
|
||
|
||
# Initialer Zustand — immer Krankengeschichte
|
||
_sel_dt("kg")
|
||
_refresh_library()
|