# -*- 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)]*>.*?", "", s) s = re.sub(r"(?is)]*>.*?", "", 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("", 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("", 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("<>", _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("", 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()