Admin-Dashboard: Konten sperren/loeschen, Audit-Log, DSGVO-Export

- Neue Seite admindash (pgAdminDash) mit Kontenuebersicht: Status,
  Patientenanzahl, Sperren/Entsperren/Loeschen mit Pflicht-Begruendung
- "Loeschen" ist bewusst Soft-Delete: Konto wird gesperrt (aktiv=0) und
  alle aktiven Patienten werden ueber die bestehende Archivierungs-
  Logik archiviert (10/30 Jahre Aufbewahrungsfrist, § 630f/§ 199 BGB)
  statt physisch geloescht zu werden
- Bestehende Hard-Delete-Route (DELETE /api/nutzer/<uid>) lehnt jetzt
  ab, solange das Konto noch Patienten besitzt - schliesst die Luecke
  verwaister Patientendatensaetze
- nutzer.aktiv wird bei jedem Request geprueft -> Sperren wirkt sofort,
  auch bei bereits offener Sitzung, ohne zusaetzlichen Session-Store
- Neue Tabelle admin_audit_log (unveraenderlich, analog notizen_verlauf):
  jede Sperr-/Entsperr-/Loesch-/Export-Aktion mit Admin, Zeitstempel,
  Ziel und Pflicht-Begruendung protokolliert
- DSGVO-Export (POST /api/admin/patienten/<pid>/dsgvo-export): admin-only,
  verlangt Rechtsgrundlage/Aktenzeichen, liefert ZIP mit allen
  Datensaetzen (daten.json) plus Dokumenten/Briefen/Fotos, in-memory
  ohne Temp-Dateien
- Selbst-Sperrung/-Loeschung durch den handelnden Admin blockiert
  (kein Lockout-Risiko)
- GET /api/nutzer liefert jetzt zusaetzlich patienten_anzahl

Verifiziert: API-Tests fuer alle Guards (Begruendung-Pflicht, Self-Lock-
Block, Hard-Delete-Block bei zugeordneten Patienten, sofortige Session-
Sperrung, 403 fuer Nicht-Admins), ZIP-Inhalt geprueft, Audit-Log-Eintraege
korrekt, Dashboard + Modal per Playwright-Screenshot visuell verifiziert.

Dritter und letzter Baustein aus refactoring.md.
This commit is contained in:
admin committed 2026-07-18 10:57:58 +02:00
1 parent bcce456cdb
commit eb57a6eccf
3 files changed
+309 -19

No files matched your search

+8 -2
View File
@@ -52,9 +52,15 @@ The product owner specified a pivot away from the original **central multi-patie
1. **Per-account patient isolation** — ✅ implemented. Data model: 1 `nutzer` = 1 account, `patienten.nutzer_id` FK, `admin` role exempt (sees everything). See "Patient isolation" above for the enforcement mechanism. Verified end-to-end (two non-admin accounts cannot see or directly access each other's patients; admin sees all).
2. **No-AI interview flow** — ✅ implemented. `pgNeu()` in `templates/index.html` is now a 6-step guided wizard (`neuWiz` state object, `neuRender()`/`neuNext()`/`neuBack()`/`neuCollect()`) that fully replaced the old single-form dialog: persönliche Daten → Kontakt & Adresse → Krankenversicherung → Pflegesituation → Einschätzung & Pflegeperson → Übersicht. Step 4 asks 5 plain-language situational questions (`NEU_FRAGEN`) plus a Zielgruppe question (`NEU_ZG_OPTIONEN`); `neuSchaetzung()` sums the answers into a **rule-based, non-AI** Pflegegrad range shown with a prominent disclaimer that it's not an official determination (only the Medizinischer Dienst's Begutachtung is). Server enforces a hard cap of 3 patients per non-admin account in `api_neuer_patient` (admin exempt).
3. **Admin dashboard** — not started. Lock/delete accounts, and export archived patient data under lawful (attorney/court) order, with GDPR (DSGVO) compliance as a hard requirement.
3. **Admin dashboard** — ✅ implemented. New `admindash` page (`pgAdminDash()` in `templates/index.html`, nav item under `.sb-admin`), backed by `app.py` routes under `/api/admin/nutzer/<uid>/{sperren,entsperren,loeschen}`, `/api/admin/audit-log`, `/api/admin/patienten/<pid>/dsgvo-export`. Key design points, all confirmed with the user beforehand:
- **"Löschen" is a soft-delete**, not `DELETE FROM nutzer`: it sets `aktiv=0` and archives every active patient the account owns via `_archiviere_patient()` (same 10/30-year retention-deadline logic as the existing per-patient archive feature) — because care records carry a statutory retention duty (§ 630f BGB) that a hard delete would violate. The pre-existing hard-delete route (`DELETE /api/nutzer/<uid>`) still exists but now refuses (400) if the account still owns any patients, forcing admins through the archive-first path.
- **`nutzer.aktiv` is checked on every request** (`_get_current_user()`), so locking an account invalidates an already-open session immediately — no separate session-store/invalidation mechanism was needed.
- **Every action requires a `begruendung`** (400 without one) and is written to the new `admin_audit_log` table (`_log_admin_aktion()`) — immutable, insert-only, same audit-trail pattern as `notizen_verlauf`. This is the operator's evidentiary record for disputes.
- **DSGVO export** (`POST /api/admin/patienten/<pid>/dsgvo-export`) requires a `begruendung` (used as Rechtsgrundlage/Aktenzeichen), is admin-only, and streams back a ZIP (built in-memory with `zipfile`/`io.BytesIO`, no temp files) containing `daten.json` (patient + antraege + leistungen + aufgaben + pflegetagebuch + dokumente + notizen_verlauf, plus an `export_info` block) and the actual files from `static/briefe/<pid>/`, `static/dokumente/<pid>/`, and the patient's photo. Every export is itself audit-logged.
- Self-targeting is blocked for sperren/loeschen (an admin can't lock/soft-delete their own account) to avoid a lockout with no recovery path.
- `GET /api/nutzer` now also returns `patienten_anzahl` per account (used by the dashboard table; backward-compatible addition).
The user has asked to be asked clarifying questions about architecture/data model before each piece of this refactor. Confirmed decisions so far: 1 nutzer = 1 account (no separate `familien` entity), admin bypasses isolation entirely, interview flow requires login (no public pre-account onboarding), interview fully replaced the old form rather than existing alongside it, 3-patient cap is hard-enforced server-side. Apply the same "ask before assuming" approach to the remaining admin-dashboard piece.
All three refactoring.md pivot pieces are now implemented. Verified end-to-end (API tests for every guard/happy-path + audit-log content, and a Playwright screenshot pass of the dashboard UI and the confirmation modal).
## Known constraints worth knowing before touching auth/security-sensitive code
+189 -16
View File
@@ -3,13 +3,13 @@ Pflegepilot Alpha – Flask + SQLite Web-App
Vollständig mit 82-Leistungen-Katalog integriert
"""
import sqlite3, os, json, re, base64, urllib.request, urllib.error, time, functools
import smtplib
import smtplib, io, zipfile
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from datetime import datetime, date, timedelta
from flask import Flask, render_template, request, jsonify, g, session
from flask import Flask, render_template, request, jsonify, g, session, Response
from werkzeug.security import generate_password_hash, check_password_hash
from katalog import (KATALOG, get_all, get_by_zielgruppe, get_by_pflegegrad,
get_by_kategorie, get_kategorien, search, get_by_id,
@@ -60,6 +60,17 @@ def init_db():
"ALTER TABLE patienten ADD COLUMN archiv_frist TEXT",
"ALTER TABLE patienten ADD COLUMN archiv_hinweis TEXT",
"ALTER TABLE patienten ADD COLUMN nutzer_id INTEGER REFERENCES nutzer(id)",
"""CREATE TABLE IF NOT EXISTS admin_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
admin_name TEXT,
aktion TEXT NOT NULL,
ziel_typ TEXT NOT NULL,
ziel_id INTEGER,
ziel_bezeichnung TEXT,
begruendung TEXT NOT NULL,
erstellt_am TEXT DEFAULT (datetime('now','localtime'))
)""",
"""CREATE TABLE IF NOT EXISTS dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER NOT NULL REFERENCES patienten(id) ON DELETE CASCADE,
@@ -155,6 +166,39 @@ def _get_owned_patient(pid):
"SELECT * FROM patienten WHERE id=? AND nutzer_id=?", (pid, u["id"])
).fetchone()
# ─── Admin-Dashboard: Audit-Log & Archivierungs-Helper ───────────────────────
def _log_admin_aktion(aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung):
"""Unveränderlicher Audit-Trail für Admin-Aktionen (Sperren/Löschen/Export) –
Nachweis für den Betreiber, wer wann was mit welcher Begründung getan hat."""
u = _get_current_user()
db = get_db()
db.execute("""
INSERT INTO admin_audit_log (admin_id, admin_name, aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung)
VALUES (?,?,?,?,?,?,?)
""", (u["id"] if u else None, (u["name"] or u["benutzername"]) if u else "System",
aktion, ziel_typ, ziel_id, ziel_bezeichnung, begruendung))
db.commit()
def _berechne_archiv_frist(status_datum, status):
# § 199 Abs. 2 BGB (30 Jahre Verstorbene) bzw. § 630f BGB (10 Jahre sonst)
jahre = 30 if status == "verstorben" else 10
try:
dt = datetime.strptime(status_datum, "%Y-%m-%d")
return dt.replace(year=dt.year + jahre).strftime("%Y-%m-%d"), jahre
except Exception:
return "", jahre
def _archiviere_patient(db, pid, status, hinweis, status_datum=None):
status_datum = status_datum or date.today().isoformat()
archiv_frist, jahre = _berechne_archiv_frist(status_datum, status)
db.execute("""
UPDATE patienten
SET patient_status=?, status_datum=?, archiv_frist=?, archiv_hinweis=?,
geaendert_am=datetime('now','localtime')
WHERE id=?
""", (status, status_datum, archiv_frist, hinweis, pid))
return archiv_frist, jahre
def login_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
@@ -233,7 +277,14 @@ def api_nutzer_list():
rows = db.execute(
"SELECT id, benutzername, name, rolle, aktiv, erstellt_am FROM nutzer ORDER BY erstellt_am"
).fetchall()
return jsonify([dict(r) for r in rows])
result = []
for r in rows:
d = dict(r)
d["patienten_anzahl"] = db.execute(
"SELECT COUNT(*) FROM patienten WHERE nutzer_id=?", (r["id"],)
).fetchone()[0]
result.append(d)
return jsonify(result)
@app.route("/api/nutzer", methods=["POST"])
@admin_required
@@ -274,10 +325,144 @@ def api_nutzer_delete(uid):
if uid == session.get("nutzer_id"):
return jsonify({"error": "Eigenen Account nicht löschbar"}), 400
db = get_db()
anzahl = db.execute("SELECT COUNT(*) FROM patienten WHERE nutzer_id=?", (uid,)).fetchone()[0]
if anzahl > 0:
return jsonify({
"error": f"Konto hat noch {anzahl} zugeordnete Patient(en) – bitte zuerst über das Admin-Dashboard sperren/löschen."
}), 400
db.execute("DELETE FROM nutzer WHERE id=?", (uid,))
db.commit()
return jsonify({"ok": True})
# ─── API: Admin-Dashboard (Konten sperren/löschen, Audit-Log, DSGVO-Export) ──
# "Löschen" ist bewusst ein Soft-Delete: das Konto wird gesperrt und seine
# aktiven Patienten werden archiviert (mit der bestehenden Aufbewahrungsfrist-
# Logik), statt Datensätze physisch zu entfernen – Pflegeunterlagen unterliegen
# einer gesetzlichen Aufbewahrungspflicht (§ 630f BGB), die einem sofortigen
# Hard-Delete entgegensteht. Jede Aktion hier verlangt eine Begründung und wird
# unveränderlich in admin_audit_log protokolliert.
@app.route("/api/admin/nutzer/<int:uid>/sperren", methods=["POST"])
@admin_required
def api_admin_nutzer_sperren(uid):
if uid == session.get("nutzer_id"):
return jsonify({"error": "Eigenes Konto kann nicht gesperrt werden"}), 400
grund = ((request.json or {}).get("begruendung") or "").strip()
if not grund:
return jsonify({"error": "Begründung erforderlich"}), 400
db = get_db()
n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone()
if not n:
return jsonify({"error": "Nicht gefunden"}), 404
db.execute("UPDATE nutzer SET aktiv=0 WHERE id=?", (uid,))
db.commit()
_log_admin_aktion("konto_gesperrt", "nutzer", uid, n["benutzername"], grund)
return jsonify({"ok": True})
@app.route("/api/admin/nutzer/<int:uid>/entsperren", methods=["POST"])
@admin_required
def api_admin_nutzer_entsperren(uid):
grund = ((request.json or {}).get("begruendung") or "").strip()
if not grund:
return jsonify({"error": "Begründung erforderlich"}), 400
db = get_db()
n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone()
if not n:
return jsonify({"error": "Nicht gefunden"}), 404
db.execute("UPDATE nutzer SET aktiv=1 WHERE id=?", (uid,))
db.commit()
_log_admin_aktion("konto_entsperrt", "nutzer", uid, n["benutzername"], grund)
return jsonify({"ok": True})
@app.route("/api/admin/nutzer/<int:uid>/loeschen", methods=["POST"])
@admin_required
def api_admin_nutzer_loeschen(uid):
if uid == session.get("nutzer_id"):
return jsonify({"error": "Eigenes Konto kann nicht gelöscht werden"}), 400
grund = ((request.json or {}).get("begruendung") or "").strip()
if not grund:
return jsonify({"error": "Begründung erforderlich"}), 400
db = get_db()
n = db.execute("SELECT benutzername FROM nutzer WHERE id=?", (uid,)).fetchone()
if not n:
return jsonify({"error": "Nicht gefunden"}), 404
db.execute("UPDATE nutzer SET aktiv=0 WHERE id=?", (uid,))
patienten = db.execute(
"SELECT id FROM patienten WHERE nutzer_id=? AND patient_status NOT IN ('archiviert','verstorben')",
(uid,)
).fetchall()
hinweis = f"Konto gelöscht durch Admin – Grund: {grund}"
for p in patienten:
_archiviere_patient(db, p["id"], "archiviert", hinweis)
db.commit()
_log_admin_aktion("konto_geloescht", "nutzer", uid, n["benutzername"],
grund + f" ({len(patienten)} Patient(en) archiviert)")
return jsonify({"ok": True, "archivierte_patienten": len(patienten)})
@app.route("/api/admin/audit-log", methods=["GET"])
@admin_required
def api_admin_audit_log():
db = get_db()
rows = db.execute(
"SELECT * FROM admin_audit_log ORDER BY erstellt_am DESC LIMIT 200"
).fetchall()
return jsonify([dict(r) for r in rows])
@app.route("/api/admin/patienten/<int:pid>/dsgvo-export", methods=["POST"])
@admin_required
def api_admin_dsgvo_export(pid):
grund = ((request.json or {}).get("begruendung") or "").strip()
if not grund:
return jsonify({"error": "Rechtsgrundlage / Aktenzeichen erforderlich"}), 400
db = get_db()
p = db.execute("SELECT * FROM patienten WHERE id=?", (pid,)).fetchone()
if not p:
return jsonify({"error": "Nicht gefunden"}), 404
u = _get_current_user()
daten = {
"patient": dict(p),
"antraege": [dict(r) for r in db.execute("SELECT * FROM antraege WHERE patient_id=?", (pid,)).fetchall()],
"leistungen": [dict(r) for r in db.execute("SELECT * FROM leistungen WHERE patient_id=?", (pid,)).fetchall()],
"aufgaben": [dict(r) for r in db.execute("SELECT * FROM aufgaben WHERE patient_id=?", (pid,)).fetchall()],
"pflegetagebuch": [dict(r) for r in db.execute("SELECT * FROM pflegetagebuch WHERE patient_id=?", (pid,)).fetchall()],
"dokumente": [dict(r) for r in db.execute("SELECT * FROM dokumente WHERE patient_id=?", (pid,)).fetchall()],
"notizen_verlauf": [dict(r) for r in db.execute("SELECT * FROM notizen_verlauf WHERE patient_id=?", (pid,)).fetchall()],
"export_info": {
"exportiert_von": (u["name"] or u["benutzername"]) if u else None,
"exportiert_am": datetime.now().isoformat(),
"rechtsgrundlage": grund,
"hinweis": "Export gemäß Art. 15/20 DSGVO bzw. rechtlicher/anwaltlicher Anordnung. "
"Enthält alle in Pflegepilot gespeicherten Daten zu diesem Patienten inkl. Dokumenten."
}
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("daten.json", json.dumps(daten, indent=2, ensure_ascii=False, default=str))
for ordner in ("briefe", "dokumente"):
src_dir = os.path.join(BASE_DIR, "static", ordner, str(pid))
if os.path.isdir(src_dir):
for fn in os.listdir(src_dir):
full = os.path.join(src_dir, fn)
if os.path.isfile(full):
zf.write(full, arcname=f"{ordner}/{fn}")
foto_pfad = p["foto_pfad"]
if foto_pfad:
full = os.path.join(BASE_DIR, "static", "fotos", foto_pfad)
if os.path.isfile(full):
zf.write(full, arcname=f"fotos/{foto_pfad}")
buf.seek(0)
_log_admin_aktion("dsgvo_export", "patient", pid, f"{p['vorname']} {p['nachname']}", grund)
dateiname = f"DSGVO_Export_{p['nachname']}_{pid}_{date.today().isoformat()}.zip"
safe_dateiname = re.sub(r'[^\w\-.]', '_', dateiname)
return Response(
buf.getvalue(),
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="{safe_dateiname}"'}
)
# ─── API: Admin / Backend-Einstellungen ──────────────────────────────────────
@app.route("/api/admin/check", methods=["GET"])
def api_admin_check():
@@ -1098,19 +1283,7 @@ def api_patient_archivieren(pid):
status = d.get("status", "archiviert")
status_datum = d.get("status_datum", date.today().isoformat())
archiv_hinweis = d.get("archiv_hinweis", "")
# Aufbewahrungsfrist: 30 Jahre Verstorbene (§ 199 Abs. 2 BGB), sonst 10 Jahre (§ 630f BGB)
jahre = 30 if status == "verstorben" else 10
try:
dt = datetime.strptime(status_datum, "%Y-%m-%d")
archiv_frist = dt.replace(year=dt.year + jahre).strftime("%Y-%m-%d")
except Exception:
archiv_frist = ""
db.execute("""
UPDATE patienten
SET patient_status=?, status_datum=?, archiv_frist=?, archiv_hinweis=?,
geaendert_am=datetime('now','localtime')
WHERE id=?
""", (status, status_datum, archiv_frist, archiv_hinweis, pid))
archiv_frist, jahre = _archiviere_patient(db, pid, status, archiv_hinweis, status_datum)
db.commit()
return jsonify({"ok": True, "archiv_frist": archiv_frist, "jahre": jahre})
@@ -39,6 +39,11 @@ body{font-family:'IBM Plex Sans',sans-serif;background:var(--bg);color:var(--tex
.sb-admin{border-top:1px solid var(--border);padding:4px 0 2px}
.ni[data-p="backend"].on{background:var(--blbg);color:var(--bltx);border-left-color:var(--bltx)}
.ni[data-p="backend"]:hover{background:var(--blbg);color:var(--bltx)}
.ni[data-p="admindash"].on{background:var(--rdbg);color:var(--rdtx);border-left-color:var(--rdtx)}
.ni[data-p="admindash"]:hover{background:var(--rdbg);color:var(--rdtx)}
.audit-row{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid var(--border);font-size:13px}
.audit-row:last-child{border-bottom:none}
.audit-aktion{font-weight:500;flex-shrink:0;min-width:130px}
.admin-pill{font-size:10px;padding:1px 7px;border-radius:9px;background:var(--ambg);color:var(--amtx);margin-left:auto;font-weight:500}
.api-token-wrap{position:relative}
.api-token-wrap input{padding-right:38px}
@@ -276,6 +281,7 @@ textarea{resize:vertical;min-height:80px}
</nav>
<div class="sb-admin">
<div class="ni" data-p="backend"><i class="ti ti-settings-2"></i>Backend<span class="admin-pill">Admin</span></div>
<div class="ni" data-p="admindash"><i class="ti ti-shield-lock"></i>Admin-Dashboard<span class="admin-pill">Admin</span></div>
</div>
<div class="sb-user" id="sb-user" style="display:none">
<div class="sb-user-info">
@@ -322,7 +328,7 @@ function go(name,extra){
if(el)el.classList.add('on');
({dashboard:pgDash,patienten:pgPatienten,neu:pgNeu,katalog:pgKatalog,rechner:pgRechner,
antrag:pgAntrag,widerspruch:pgWiderspruch,musterbrief:pgMuster,aufgaben:pgAufgaben,tagebuch:pgTagebuch,
kalender:pgKalender,backend:pgBackend})[name]?.(extra);
kalender:pgKalender,backend:pgBackend,admindash:pgAdminDash})[name]?.(extra);
}
async function api(path,opts={}){
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});
@@ -1069,6 +1075,111 @@ async function uploadLogo(input){
else toast(j.error||'Fehler',false);
}
// ── Admin-Dashboard: Konten sperren/löschen, DSGVO-Export, Audit-Log ────────
const ADMIN_AKTION_LABEL={konto_gesperrt:'Konto gesperrt',konto_entsperrt:'Konto entsperrt',
konto_geloescht:'Konto gelöscht',dsgvo_export:'DSGVO-Export'};
async function pgAdminDash(){
R('<div class="spin-wrap"><div class="spin"></div>Lade…</div>','Admin-Dashboard');
const [nutzer,log,patienten]=await Promise.all([
api('/api/nutzer'),api('/api/admin/audit-log'),api('/api/patienten')
]);
renderAdminDash(nutzer,log,patienten);
}
function renderAdminDash(nutzer,log,patienten){
const rows=(nutzer||[]).map(n=>{
const gesperrt=!n.aktiv;
const isSelf=n.id===curUser?.id;
return '<tr>'+
'<td>'+av(n.name||n.benutzername,n.id)+'</td>'+
'<td><div class="w5">'+escH(n.name||n.benutzername)+'</div><div class="sm muted">@'+escH(n.benutzername)+'</div></td>'+
'<td>'+(n.rolle==='admin'?'<span class="badge b-b">Admin</span>':'<span class="badge b-gr">Nutzer</span>')+'</td>'+
'<td>'+(gesperrt?'<span class="badge b-r">Gesperrt</span>':'<span class="badge b-g">Aktiv</span>')+'</td>'+
'<td>'+n.patienten_anzahl+'</td>'+
'<td>'+(isSelf?'<span class="sm muted">Eigenes Konto</span>':
'<div class="fl g8">'+
(gesperrt?
'<button class="btn btn-sm" onclick="adminNutzerModal('+n.id+',\''+escH(n.name||n.benutzername)+'\',\'entsperren\')"><i class="ti ti-lock-open"></i>Entsperren</button>':
'<button class="btn btn-sm" onclick="adminNutzerModal('+n.id+',\''+escH(n.name||n.benutzername)+'\',\'sperren\')"><i class="ti ti-lock"></i>Sperren</button>'
)+
'<button class="btn btn-sm" style="color:var(--rdtx)" onclick="adminNutzerModal('+n.id+',\''+escH(n.name||n.benutzername)+'\',\'loeschen\')"><i class="ti ti-trash"></i>Löschen</button>'+
'</div>')+
'</td></tr>';
}).join('');
const patOptions=(patienten||[]).map(p=>'<option value="'+p.id+'">'+escH(p.vorname+' '+p.nachname)+' (#'+p.id+')</option>').join('');
const logRows=(log||[]).length?(log||[]).map(l=>
'<div class="audit-row">'+
'<div class="mono sm muted" style="min-width:135px">'+fdate(l.erstellt_am)+'</div>'+
'<div class="audit-aktion">'+(ADMIN_AKTION_LABEL[l.aktion]||l.aktion)+'</div>'+
'<div style="flex:1">'+
'<div>'+escH(l.ziel_bezeichnung||'')+' <span class="muted sm">('+l.ziel_typ+' #'+l.ziel_id+')</span></div>'+
'<div class="sm muted">von '+escH(l.admin_name||'–')+' – '+escH(l.begruendung||'')+'</div>'+
'</div></div>'
).join(''):'<div class="muted sm">Noch keine Einträge.</div>';
R('<div class="card mb4">'+
'<div class="slabel mb3">Konten</div>'+
'<div class="tw"><table><thead><tr><th></th><th>Name</th><th>Rolle</th><th>Status</th><th>Patienten</th><th>Aktionen</th></tr></thead>'+
'<tbody>'+rows+'</tbody></table></div>'+
'</div>'+
'<div class="card mb4">'+
'<div class="slabel mb3">DSGVO-Export (auf anwaltliche/gerichtliche Anordnung)</div>'+
'<div class="wiz-hint">Exportiert alle gespeicherten Daten eines Patienten (Stammdaten, Anträge, Leistungen, Pflegetagebuch, Notizen-Verlauf) sowie die zugehörigen Dokumente/Briefe/Fotos als ZIP-Datei. Eine Rechtsgrundlage oder ein Aktenzeichen ist Pflicht und wird protokolliert.</div>'+
'<div class="fg c3 mb4">'+
'<div class="fgrp"><label>Patient</label><select id="dsgvo-pid"><option value="">– auswählen –</option>'+patOptions+'</select></div>'+
'<div class="fgrp" style="grid-column:span 2"><label>Rechtsgrundlage / Aktenzeichen *</label><input id="dsgvo-grund" placeholder="z. B. Az. 12 O 345/26, Anwaltskanzlei Muster, Beschluss vom …"></div>'+
'</div>'+
'<button class="btn btn-p" onclick="dsgvoExport()"><i class="ti ti-download"></i>Export herunterladen</button>'+
'</div>'+
'<div class="card">'+
'<div class="slabel mb3">Audit-Log</div>'+
logRows+
'</div>',
'Admin-Dashboard');
}
function adminNutzerModal(uid,name,aktion){
const titel={sperren:'Konto sperren',entsperren:'Konto entsperren',loeschen:'Konto löschen'}[aktion];
const hinweis=aktion==='loeschen'
?'Das Konto wird gesperrt und alle zugeordneten Patienten werden gemäß gesetzlicher Aufbewahrungsfrist archiviert. Es werden keine Daten physisch gelöscht.'
:(aktion==='sperren'?'Der Nutzer kann sich danach nicht mehr anmelden (wirkt sofort, auch bei bestehender Sitzung).':'Der Nutzer kann sich danach wieder anmelden.');
OM(titel+' – '+escH(name),
'<div class="wiz-hint">'+hinweis+'</div>'+
'<div class="fgrp mb4"><label>Begründung *</label><textarea id="admin-grund" rows="3" placeholder="Pflichtangabe – wird im Audit-Log gespeichert"></textarea></div>'+
'<div class="fl g8" style="justify-content:flex-end">'+
'<button class="btn" onclick="CM()">Abbrechen</button>'+
'<button class="btn btn-p" onclick="adminNutzerAktion('+uid+',\''+aktion+'\')">'+
'<i class="ti ti-check"></i>Bestätigen</button></div>'
);
}
async function adminNutzerAktion(uid,aktion){
const grund=(document.getElementById('admin-grund')?.value||'').trim();
if(!grund){toast('Begründung erforderlich',false);return;}
const r=await api('/api/admin/nutzer/'+uid+'/'+aktion,{method:'POST',body:JSON.stringify({begruendung:grund})});
if(r&&r.error){toast(r.error,false);return;}
CM();
toast('Erledigt'+(r.archivierte_patienten?' – '+r.archivierte_patienten+' Patient(en) archiviert':''));
pgAdminDash();
}
async function dsgvoExport(){
const pid=document.getElementById('dsgvo-pid')?.value;
const grund=(document.getElementById('dsgvo-grund')?.value||'').trim();
if(!pid){toast('Bitte Patient auswählen',false);return;}
if(!grund){toast('Rechtsgrundlage / Aktenzeichen erforderlich',false);return;}
const r=await fetch('/api/admin/patienten/'+pid+'/dsgvo-export',{
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({begruendung:grund})
});
if(!r.ok){
const e=await r.json().catch(()=>({error:'Export fehlgeschlagen'}));
toast(e.error||'Export fehlgeschlagen',false);return;
}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;a.download='DSGVO_Export_Patient_'+pid+'_'+isoDate()+'.zip';
document.body.appendChild(a);a.click();a.remove();
URL.revokeObjectURL(url);
toast('Export heruntergeladen');
pgAdminDash();
}
// ── Dashboard ─────────────────────────────────────────────
async function pgDash(){
R('<div class="spin-wrap"><div class="spin"></div>Lade…</div>','Dashboard',