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:
1 parent
bcce456cdb
commit
eb57a6eccf
3 files changed
+309
-19
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user