open.md Punkte 1-4: Admin-Fokus, Selbstregistrierung, Rechnungen, Rechtstexte

- KI-Claude-API-Bereich im Backend entfernt (ungenutzt).
- Impressum/Datenschutz/Nutzungsbedingungen im Backend editierbar, oeffentlich
  abrufbar, per Sidebar-Link erreichbar - Betrieb-Karte bewusst NICHT entfernt
  (wird fuer PDF-Briefkopf/Sidebar-Branding aktiv gebraucht, Abweichung von
  der urspruenglichen open.md-Formulierung dokumentiert).
- Admin-Sidebar zeigt nur noch admin-relevante Bereiche, Login/Reload leitet
  Admin direkt auf das Admin-Dashboard statt das allgemeine Dashboard.
- Admin-Dashboard ist jetzt die vollstaendige Nutzerverwaltung: Bearbeiten-
  Button (inkl. Rechnungsanschrift), Registriert-am/Letzter-Login/IP-Spalten.
- Selbstregistrierung (POST /api/auth/registrieren, oeffentlich) mit
  Pflichtfeld Rechnungsanschrift; neue Konten duerfen sofort lesen, aber
  Briefe/Leistungen/Kalender-Eintraege anlegen ist gesperrt bis ein Abo
  vorliegt (selbst_registriert-Flag, unabhaengig vom globalen
  abo_pflicht-Schalter - admin-angelegte Konten sind nicht betroffen).
- Admin kann PDF-Rechnungen pro Konto erstellen (clientseitig, gleiche
  Technik wie Musterbrief/Widerspruch).
- Login-Seite zeigt keine Standard-Zugangsdaten (admin/admin) mehr an.
- Changelog-Eintrag 1.2.0 ergaenzt (siehe Aenderungs-Policy in CLAUDE.md).

Verifiziert per curl (kein Playwright, wie gewuenscht): alle vier Punkte
end-to-end getestet inkl. Schreibsperre/Freischaltung/Rechte-Trennung admin-
vs. selbstregistriert, sauberer Docker-Rebuild auf leerem Volume.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-07-18 19:53:23 +02:00
1 parent d8097e1cf5
commit 44b459bce6
5 files changed
+401 -68

No files matched your search

+13 -1
View File
@@ -62,7 +62,7 @@ docker compose down # add -v to also wipe the db/static volumes
- **Auth**: session-cookie based. `_get_current_user()` / `_check_admin()` read `session["nutzer_id"]`; `login_required` / `admin_required` decorators guard individual routes, while `global_auth_check()` (a Flask `before_request` hook) blanket-blocks any `/api/*` path not in the small public allowlist (`/api/auth/login`, `/api/auth/me`, `/api/einstellungen/public`). Two roles exist: `admin` and `nutzer`. Passwords are hashed with `werkzeug.security` (PBKDF2).
- **Patient isolation**: `patienten.nutzer_id` (FK to `nutzer.id`) records who owns each patient — set automatically to the creating user on `POST /api/patienten`. Two helpers in `app.py` enforce it everywhere: `_get_owned_patient(pid)` (single-patient routes — returns the row if the current user is the owner or an admin, else `None` → caller returns 404) and `_scope_sql(column="id")` (list/aggregate routes — returns a SQL fragment + params to append to a `WHERE` clause; empty for admins, `AND {column} IN (SELECT id FROM patienten WHERE nutzer_id=?)` otherwise). `admin` role is exempt and always sees every patient. Every route touching `patienten` or a child table (`antraege`, `leistungen`, `aufgaben`, `pflegetagebuch`, `dokumente`) must go through one of these two helpers — when adding a new patient-scoped route, follow the existing pattern rather than querying the table directly. `aufgaben.patient_id` is nullable in the schema, but the API now requires a `patient_id` from non-admin callers on creation (admins may still create patient-less tasks) so the isolation model stays consistent between write and read paths.
- **File storage**: everything is local under `static/` — `static/fotos/<patient_id>.<ext>` (profile photos), `static/briefe/<patient_id>/…` (generated letter PDFs), `static/dokumente/<patient_id>/…` (uploaded documents), `static/logo/` (company branding). No object storage/CDN involved.
- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds company branding/address and an (unused-in-current-flow) Claude API token — surfaced through `/api/admin/einstellungen`.
- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds company branding/address and the three `rechtstext_*` legal texts (see "Admin focus, self-registration & legal pages" below) — surfaced through `/api/admin/einstellungen`. The earlier "KI – Claude API" token field/card was removed (unused, never wired to any actual AI feature) — don't resurrect it without a real integration behind it.
## Planned architecture pivot (see `refactoring.md`)
@@ -143,6 +143,18 @@ The demo account can create up to 2 more patients itself (same 3-per-account cap
Frontend: `#demo-badge` in the topbar (toggled in `initApp()` from `me.ist_demo`) makes it visually obvious when a session is in demo mode. Login screen has a dedicated "Demo ausprobieren" button (`doDemoLogin()`) that fills the credentials and reuses `doLogin()` — no separate login code path.
## Admin focus, self-registration & legal pages
Four related pieces shipped together, all from `open.md` items surfaced by real usage after the Docker test run:
- **Admin's sidebar is decluttered**: any `.sb-sec`/`.ni` element carrying `data-role="nutzer"` (Patienten, Leistungskatalog, Formulare & Recht, Aufgaben & Fristen, Mein Abo) is hidden for `rolle==='admin'` in `initApp()` — admin has no patients of their own, so those sections are noise. Admin lands on `admindash` (not `dashboard`) right after login (`doLogin()`) and on page reload (the bottom `initApp().then(...)` call) — Nutzerverwaltung is the admin's actual job here, not care-record data entry.
- **Admin-Dashboard is now the one-stop nutzer-management view**: `renderAdminDash()`'s Konten table gained `Registriert` (`erstellt_am`) and `Letzter Login` (+ IP) columns, plus a **Bearbeiten** button (`adminNutzerBearbeitenModal()`) that edits name/rolle/aktiv/passwort *and* the Rechnungsanschrift in one modal — previously only the simpler Backend "Nutzerverwaltung" card (still there, still fine for quick account creation) could edit a profile at all, and it had no billing-address or login-history visibility. `letzter_login`/`letzte_ip` (`nutzer` columns) are stamped on every successful login and self-registration in `api_auth_login()`/`api_auth_registrieren()` — `X-Forwarded-For` is honored ahead of `request.remote_addr` for when a reverse proxy sits in front (relevant once the DynDNS/testbench setup is live), taking the first hop only.
- **Self-registration** (`POST /api/auth/registrieren`, public — added to the `global_auth_check()` allowlist): a "Noch kein Konto? Jetzt registrieren" link on the login screen (`showRegisterForm()`/`hideRegisterForm()` toggle the same `.lo-box`, no separate page) collects Benutzername/Passwort/Name plus a **mandatory** Rechnungsanschrift (`rg_name`/`rg_strasse`/`rg_plz`/`rg_ort` on `nutzer`) and auto-logs the new account in on success (`doRegister()`). New accounts are marked `selbst_registriert=1` — admin-created accounts (`POST /api/nutzer`, still admin-only) stay `0` and are deliberately unaffected by everything below, so nothing changes for how admin onboards trusted/manual accounts.
- **Freemium write-lock for unpaid self-registrations**: `_hat_bezahlten_zugang(u)` (real `abo_bis` check, admin/demo always true) is deliberately separate from `_hat_aktiven_zugang()`/`abo_pflicht` — a self-registered account without a paid/granted subscription can browse and read *everything*, but `global_auth_check()` blocks `POST`/`PUT` to the letter-generation (`.../briefe`), Leistungen-assignment (`.../leistungen/katalog`, `.../deaktivieren`) and Aufgaben-creation (`/api/aufgaben`) endpoints with a 402 regardless of the global `abo_pflicht` toggle. This only applies when `selbst_registriert=1`, so it can go live immediately (no PayPal needed yet) without touching existing admin-created accounts. `GET /api/auth/me` exposes `schreibzugriff_gesperrt` so the frontend can short-circuit before doing client-side work (PDF generation, etc.) rather than relying on the 402 alone — `_schreibgesperrt()` in `templates/index.html` is the shared guard, called at the top of `dlM()`/`dlW()`/`sendMail()`/`assignL()`/`saveAufg()`; it toasts and redirects to `meinabo`. Lifting the lock is just admin granting `abo-freischalten` (existing mechanism) or, eventually, real payment — no separate "unlock" code path needed.
- **Admin can generate a PDF invoice** per account (`rechnungModal()` → `rechnungErstellen()` in `templates/index.html`) — entirely client-side via the same `html2pdf.js` pattern as Musterbrief/Widerspruch (Courier-style text block, `addPageNums()`), pre-filled from the account's Rechnungsanschrift and the `abo_preis` setting, downloaded on demand. No server-side persistence/archival of invoices (unlike patient documents) — these aren't care records under the §630f BGB retention duty, so a simple download suffices for "on customer request."
- **Legal pages**: three `rechtstext_impressum`/`rechtstext_datenschutz`/`rechtstext_agb` keys in `einstellungen`, edited as plain textareas in the Backend-Einstellungen card that replaced "KI – Claude API" (same slot, same `saveEinst()` pattern). They're deliberately included in the **public** `/api/einstellungen/public` payload (no login required) since Impressum/Datenschutz shouldn't hide behind a login wall, and `pgRechtstext(typ)` (routed via `go('impressum'|'datenschutz'|'agb')`) reads them straight from the already-cached `_einstellungen` object. A small `.sb-legal` link row was added to the sidebar bottom, and `go()` exempts these three page names from the `aboBlockiert` redirect-to-`meinabo` logic — a payment-blocked account must still be able to read the legal pages.
- **The "Betrieb" card was deliberately kept**, not removed, despite the original `open.md` wording ("das kann weg") — `firmenname`/`firmen_logo`/`firmen_strasse`/etc. are actively used for sidebar branding and the "Erstellt mit {firmenname}" footer on generated PDFs (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte); deleting the only UI to edit them would have been a silent regression unrelated to the actual ask. If this was in fact meant to go, that's a call for the product owner, not something to infer from "veraltet."
## Known constraints worth knowing before touching auth/security-sensitive code
- `app.secret_key` in `app.py` is a hardcoded string, not loaded from an environment variable.
+18 -16
View File
@@ -1,25 +1,27 @@
offene Aufgaben: sortiere die Aufgaben nach korrekter Reihenfolge, schreibe nach dieser zeile um für KI-Lesbarkeit, fertige aufgaben durchstreichen und als erledigt markieren.
Reihenfolge unten nach Abhängigkeit/Aufwand sortiert (nicht nach ursprünglicher Nummerierung). Punkte 1+2 stammen aus Beobachtungen nach dem Docker-Testlauf, betreffen beide dieselbe Backend-Einstellungen-Seite und werden deshalb zusammen bearbeitet.
Reihenfolge unten nach Abhängigkeit/Aufwand sortiert (nicht nach ursprünglicher Nummerierung).
- [ ] **1. KI-Claude-API-Block aus dem Backend entfernen**
- Der gesamte "KI – Claude API"-Kartenblock in den Backend-Einstellungen (Karte, Eingabefeld, zugehöriger Code/Route) soll komplett verschwinden – wird nicht genutzt.
- [x] ~~**1. KI-Claude-API-Block aus dem Backend entfernen**~~ *(erledigt)*
- ~~Der gesamte "KI – Claude API"-Kartenblock in den Backend-Einstellungen (Karte, Eingabefeld, zugehöriger Code/Route) soll komplett verschwinden – wird nicht genutzt.~~
- [ ] **2. Backend "Betrieb"-Karte durch Rechtstexte ersetzen (Impressum, Datenschutz, Nutzungsbedingungen)**
- Die veraltete "Betrieb"-Karte in den Backend-Einstellungen entfällt.
- Stattdessen: drei bearbeitbare Rechtstexte-Seiten (Impressum, Datenschutzerklärung, Nutzungsbedingungen), die der Admin im Backend pflegen kann.
- Alle drei Seiten müssen für den Kunden (Nutzer) per 1-Klick erreichbar sein – am besten als fester Link-Block unten in der Sidebar.
- [x] ~~**2. Backend "Betrieb"-Karte durch Rechtstexte ersetzen (Impressum, Datenschutz, Nutzungsbedingungen)**~~ *(erledigt, mit einer Abweichung)*
- ~~Stattdessen: drei bearbeitbare Rechtstexte-Seiten (Impressum, Datenschutzerklärung, Nutzungsbedingungen), die der Admin im Backend pflegen kann.~~
- ~~Alle drei Seiten müssen für den Kunden (Nutzer) per 1-Klick erreichbar sein – am besten als fester Link-Block unten in der Sidebar.~~
- Abweichung: die "Betrieb"-Karte wurde **nicht** entfernt, da Firmenname/Logo/Adresse dort aktiv für Sidebar-Branding und die Fußzeile generierter PDFs (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte) verwendet werden – das Entfernen hätte eine echte Funktion ersatzlos gestrichen. Die Rechtstexte-Karte kam stattdessen in den frei gewordenen Slot der KI-Claude-API-Karte. Bei Bedarf bitte Rückmeldung, falls die Betrieb-Karte trotzdem weg soll (dann bräuchte die Branding-Pflege einen neuen Platz).
- Umsetzung: 3 neue `einstellungen`-Schlüssel (`rechtstext_impressum`/`_datenschutz`/`_agb`), öffentlich abrufbar (kein Login nötig, wie bei Impressumspflicht üblich), Sidebar-Fußzeile mit 3 Links, von der Abo-Sperre ausgenommen.
- [ ] **3. Nutzerverwaltung reparieren & Admin-Dashboard auf Nutzerverwaltung fokussieren**
- Bug: bei einem manuell angelegten Test-Konto lassen sich aktuell keine Aktionen ausführen – weder Sperren, noch Löschen, noch Freimonate zuweisen. Ursache klären und beheben (noch nicht untersucht).
- Für die Rolle Admin sind Leistungskatalog, Formulare & Recht usw. in der Sidebar irrelevant – im Vordergrund soll stattdessen die Nutzerverwaltung stehen.
- Nutzerverwaltung um zusätzliche Informationen je Konto erweitern: Registrierungsdatum (vorhanden), letzter Login, IP-Adresse, Zahlungsstatus (Zahlung erhalten / Zahlungsverzug).
- [x] ~~**3. Nutzerverwaltung reparieren & Admin-Dashboard auf Nutzerverwaltung fokussieren**~~ *(erledigt)*
- ~~Bug: bei einem manuell angelegten Test-Konto lassen sich aktuell keine Aktionen ausführen.~~ Ursache gefunden: Sperren/Löschen/Freimonate gab es nur im separaten Admin-Dashboard, nicht in der einfacheren "Nutzerverwaltung"-Karte im Backend, in der vermutlich nachgeschaut wurde – jetzt hat das Admin-Dashboard zusätzlich einen "Bearbeiten"-Button, damit dort alles an einem Ort ist.
- ~~Für die Rolle Admin sind Leistungskatalog, Formulare & Recht usw. in der Sidebar irrelevant.~~ Sidebar blendet diese Bereiche für Admin jetzt aus, Admin landet nach Login direkt auf dem Admin-Dashboard.
- ~~Nutzerverwaltung um zusätzliche Informationen je Konto erweitern.~~ Registriert-am, Letzter Login und IP-Adresse sind jetzt in der Konten-Tabelle sichtbar (Zahlungsstatus gab es über "Abo bis"/"abgelaufen"/"kein Zugang" schon vorher).
- [ ] **4. Rechnungsanschrift & Rechnungserstellung**
- Bei der Registrierung muss der Nutzer künftig eine Rechnungsanschrift hinterlegen (Pflichtfeld).
- Admin braucht in der Nutzerverwaltung Einsicht in die hinterlegte Rechnungsanschrift jedes Kontos.
- Admin muss auf Kundenwunsch eine Rechnung für ein Konto erstellen können (PDF, wie bei den anderen Dokumenten der App).
- Baut auf Punkt 3 auf (Nutzerverwaltung muss die Daten überhaupt anzeigen können, bevor eine Rechnung daraus erstellt werden kann).
- [x] ~~**4. Rechnungsanschrift & Rechnungserstellung**~~ *(erledigt, erweitert um Registrierung)*
- ~~Admin braucht in der Nutzerverwaltung Einsicht in die hinterlegte Rechnungsanschrift jedes Kontos.~~ Teil des neuen "Bearbeiten"-Modals im Admin-Dashboard.
- ~~Admin muss auf Kundenwunsch eine Rechnung für ein Konto erstellen können (PDF).~~ Neuer "Rechnung"-Button pro Konto, erzeugt clientseitig ein PDF (gleiche Technik wie Musterbrief/Widerspruch).
- Registrierungs-Button auf dem Login-Bildschirm umgesetzt: "Noch kein Konto? Jetzt registrieren" öffnet ein Formular (Name, Zugangsdaten, Pflichtfeld Rechnungsanschrift), Konto wird direkt eingeloggt.
- Nach der Registrierung ist Lesen/Ansehen sofort möglich, aber Briefe/Widersprüche, Leistungen-Zuweisung und Kalender-Einträge sind gesperrt, bis ein Abo vorliegt (unabhängig vom globalen Zahlungspflicht-Schalter) – Admin kann über "Freischalten" manuell freigeben.
- Den Standard-Zugangsdaten-Hinweis (admin/admin) von der Login-Seite entfernt.
- [ ] **5. ⚠️ Separates Grossvorhaben: Migration zu React + PostgreSQL**
- Vollständiger Technologiewechsel weg von Flask/vanilla-JS/SQLite — deutlich grösserer Umfang als die Punkte oben, keine Nebenbei-Aufgabe
+116 -17
View File
@@ -59,6 +59,13 @@ def init_db():
abo_typ TEXT,
ist_demo INTEGER DEFAULT 0,
letzte_gesehene_version TEXT,
letzter_login TEXT,
letzte_ip TEXT,
rg_name TEXT,
rg_strasse TEXT,
rg_plz TEXT,
rg_ort TEXT,
selbst_registriert INTEGER DEFAULT 0,
erstellt_am TEXT DEFAULT (datetime('now','localtime'))
)""",
"ALTER TABLE patienten ADD COLUMN zielgruppe TEXT DEFAULT 'alterspflege'",
@@ -78,6 +85,13 @@ def init_db():
"ALTER TABLE nutzer ADD COLUMN abo_typ TEXT",
"ALTER TABLE nutzer ADD COLUMN ist_demo INTEGER DEFAULT 0",
"ALTER TABLE nutzer ADD COLUMN letzte_gesehene_version TEXT",
"ALTER TABLE nutzer ADD COLUMN letzter_login TEXT",
"ALTER TABLE nutzer ADD COLUMN letzte_ip TEXT",
"ALTER TABLE nutzer ADD COLUMN rg_name TEXT",
"ALTER TABLE nutzer ADD COLUMN rg_strasse TEXT",
"ALTER TABLE nutzer ADD COLUMN rg_plz TEXT",
"ALTER TABLE nutzer ADD COLUMN rg_ort TEXT",
"ALTER TABLE nutzer ADD COLUMN selbst_registriert INTEGER DEFAULT 0",
"""CREATE TABLE IF NOT EXISTS admin_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
@@ -177,7 +191,7 @@ def _get_current_user():
return None
try:
return get_db().execute(
"SELECT id, benutzername, name, rolle, abo_bis, abo_typ, ist_demo, letzte_gesehene_version FROM nutzer WHERE id=? AND aktiv=1", (uid,)
"SELECT id, benutzername, name, rolle, abo_bis, abo_typ, ist_demo, letzte_gesehene_version, selbst_registriert FROM nutzer WHERE id=? AND aktiv=1", (uid,)
).fetchone()
except Exception:
return None
@@ -312,6 +326,25 @@ def _hat_aktiven_zugang(u):
except Exception:
return False
# Freemium-Grenze für selbstregistrierte Konten (siehe POST /api/auth/registrieren):
# Zugang/Lesen ist sofort möglich, aber Inhalte anlegen (Briefe, Leistungen,
# Kalender-Einträge) erst mit echtem Abo - unabhängig vom globalen abo_pflicht-
# Schalter oben, der nur den harten Vollsperr-Fall regelt. Admin-angelegte
# Konten (selbst_registriert=0) sind davon nicht betroffen, um bestehendes
# Verhalten für manuell onboardete Nutzer nicht zu verändern.
def _hat_bezahlten_zugang(u):
if u is None:
return False
if u["rolle"] == "admin" or u["ist_demo"]:
return True
abo_bis = u["abo_bis"] if "abo_bis" in u.keys() else None
if not abo_bis:
return False
try:
return datetime.strptime(abo_bis, "%Y-%m-%d").date() >= date.today()
except Exception:
return False
def _add_monate(d, monate):
monat = d.month - 1 + monate
jahr = d.year + monat // 12
@@ -411,7 +444,7 @@ def global_auth_check():
path = request.path
if not path.startswith("/api/"):
return None
public = {"/api/auth/login", "/api/auth/me", "/api/einstellungen/public"}
public = {"/api/auth/login", "/api/auth/me", "/api/einstellungen/public", "/api/auth/registrieren"}
if path in public:
return None
u = _get_current_user()
@@ -425,6 +458,24 @@ def global_auth_check():
abo_erlaubt = {"/api/abo/status", "/api/abo/aktivieren", "/api/auth/logout"}
if path not in abo_erlaubt and not _hat_aktiven_zugang(u):
return jsonify({"error": "Zahlung erforderlich – bitte Abo aktivieren.", "code": "abo_erforderlich"}), 402
# Freemium-Grenze für selbstregistrierte Konten ohne Abo: Lesen/Browsen
# bleibt erlaubt, aber Inhalte anlegen (Briefe/Widersprüche, Leistungen,
# Kalender-Einträge) ist gesperrt, bis ein Abo vorliegt - siehe
# _hat_bezahlten_zugang(). Gilt nur für request.method in POST/PUT,
# nicht für GET/DELETE, und nur für selbst_registriert=1-Konten.
if (request.method in ("POST", "PUT") and u["selbst_registriert"]
and not _hat_bezahlten_zugang(u)):
gesperrt = (
path.endswith("/briefe")
or path.endswith("/leistungen/katalog")
or (path.startswith("/api/leistungen/") and path.endswith("/deaktivieren"))
or path == "/api/aufgaben"
)
if gesperrt:
return jsonify({
"error": "Diese Funktion erfordert ein aktives Abo. Bitte Abo aktivieren.",
"code": "abo_erforderlich"
}), 402
# ─── API: Öffentliche Einstellungen (Branding) ───────────────────────────────
@app.route("/api/einstellungen/public", methods=["GET"])
@@ -434,7 +485,8 @@ def api_einstellungen_public():
s = {r["schluessel"]: r["wert"] for r in rows}
result = {}
for key in ("firmenname", "firmen_logo", "firmen_strasse", "firmen_plz",
"firmen_ort", "firmen_tel"):
"firmen_ort", "firmen_tel",
"rechtstext_impressum", "rechtstext_datenschutz", "rechtstext_agb"):
if s.get(key):
result[key] = s[key]
if s.get("firmen_logo"):
@@ -450,7 +502,9 @@ def api_auth_me():
return jsonify({"id": u["id"], "benutzername": u["benutzername"],
"name": u["name"] or u["benutzername"], "rolle": u["rolle"], "ist_demo": bool(u["ist_demo"]),
"app_version": _app_version(),
"changelog_ungesehen": _changelog_ungesehen(u["letzte_gesehene_version"])})
"changelog_ungesehen": _changelog_ungesehen(u["letzte_gesehene_version"]),
"selbst_registriert": bool(u["selbst_registriert"]),
"schreibzugriff_gesperrt": bool(u["selbst_registriert"]) and not _hat_bezahlten_zugang(u)})
@app.route("/api/auth/login", methods=["POST"])
def api_auth_login():
@@ -463,10 +517,58 @@ def api_auth_login():
if not u or not check_password_hash(u["passwort_hash"], passwort):
return jsonify({"error": "Ungültige Zugangsdaten"}), 401
session["nutzer_id"] = u["id"]
ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or request.remote_addr or "")
db.execute(
"UPDATE nutzer SET letzter_login=datetime('now','localtime'), letzte_ip=? WHERE id=?",
(ip, u["id"])
)
db.commit()
if u["ist_demo"] and _demo_reset_faellig():
_demo_reset()
return jsonify({"ok": True, "name": u["name"] or u["benutzername"], "rolle": u["rolle"], "ist_demo": bool(u["ist_demo"])})
# Öffentliche Selbstregistrierung (bewusst ohne Login-Zwang - siehe "public"-
# Allowlist in global_auth_check). Rechnungsanschrift ist Pflicht, da jedes
# selbst registrierte Konto auf ein Abo zulaeuft. Neue Konten starten mit
# vollem Lesezugriff, aber gesperrtem Schreibzugriff auf Briefe/Leistungen/
# Kalender bis ein Abo vorliegt (selbst_registriert=1, siehe
# _hat_bezahlten_zugang() und die Freemium-Sperre in global_auth_check()).
@app.route("/api/auth/registrieren", methods=["POST"])
def api_auth_registrieren():
d = request.json or {}
benutzername = (d.get("benutzername") or "").strip()
passwort = d.get("passwort", "")
name = (d.get("name") or "").strip()
rg_name = (d.get("rg_name") or "").strip()
rg_strasse = (d.get("rg_strasse") or "").strip()
rg_plz = (d.get("rg_plz") or "").strip()
rg_ort = (d.get("rg_ort") or "").strip()
if not benutzername or not passwort or not name:
return jsonify({"error": "Benutzername, Passwort und Name erforderlich"}), 400
if len(passwort) < 6:
return jsonify({"error": "Passwort muss mindestens 6 Zeichen lang sein"}), 400
if not (rg_name and rg_strasse and rg_plz and rg_ort):
return jsonify({"error": "Rechnungsanschrift (Name, Straße, PLZ, Ort) ist erforderlich"}), 400
db = get_db()
try:
cur = db.execute(
"INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version, "
"rg_name, rg_strasse, rg_plz, rg_ort, selbst_registriert) VALUES (?,?,?,?,?,?,?,?,?,1)",
(benutzername, generate_password_hash(passwort), name, "nutzer", _app_version(),
rg_name, rg_strasse, rg_plz, rg_ort)
)
db.commit()
except Exception:
return jsonify({"error": "Benutzername bereits vergeben"}), 409
session["nutzer_id"] = cur.lastrowid
ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or request.remote_addr or "")
db.execute("UPDATE nutzer SET letzter_login=datetime('now','localtime'), letzte_ip=? WHERE id=?",
(ip, cur.lastrowid))
db.commit()
return jsonify({"ok": True, "name": name, "rolle": "nutzer", "ist_demo": False})
@app.route("/api/changelog", methods=["GET"])
def api_changelog():
return jsonify({"version": _app_version(), "eintraege": _changelog_alle()})
@@ -575,7 +677,9 @@ def api_abo_aktivieren():
def api_nutzer_list():
db = get_db()
rows = db.execute(
"SELECT id, benutzername, name, rolle, aktiv, erstellt_am, abo_bis, abo_typ FROM nutzer ORDER BY erstellt_am"
"SELECT id, benutzername, name, rolle, aktiv, erstellt_am, abo_bis, abo_typ, "
"letzter_login, letzte_ip, rg_name, rg_strasse, rg_plz, rg_ort, selbst_registriert "
"FROM nutzer ORDER BY erstellt_am"
).fetchall()
pflicht = _abo_pflicht_aktiv()
heute = date.today()
@@ -608,9 +712,11 @@ def api_nutzer_create():
db = get_db()
try:
cur = db.execute(
"INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version) VALUES (?,?,?,?,?)",
"INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version, "
"rg_name, rg_strasse, rg_plz, rg_ort) VALUES (?,?,?,?,?,?,?,?,?)",
(benutzername, generate_password_hash(passwort),
d.get("name", ""), d.get("rolle", "nutzer"), _app_version())
d.get("name", ""), d.get("rolle", "nutzer"), _app_version(),
d.get("rg_name", ""), d.get("rg_strasse", ""), d.get("rg_plz", ""), d.get("rg_ort", ""))
)
db.commit()
return jsonify({"ok": True, "id": cur.lastrowid})
@@ -625,8 +731,9 @@ def api_nutzer_update(uid):
if d.get("passwort"):
db.execute("UPDATE nutzer SET passwort_hash=? WHERE id=?",
(generate_password_hash(d["passwort"]), uid))
db.execute("UPDATE nutzer SET name=?, rolle=?, aktiv=? WHERE id=?",
(d.get("name", ""), d.get("rolle", "nutzer"), int(d.get("aktiv", 1)), uid))
db.execute("UPDATE nutzer SET name=?, rolle=?, aktiv=?, rg_name=?, rg_strasse=?, rg_plz=?, rg_ort=? WHERE id=?",
(d.get("name", ""), d.get("rolle", "nutzer"), int(d.get("aktiv", 1)),
d.get("rg_name", ""), d.get("rg_strasse", ""), d.get("rg_plz", ""), d.get("rg_ort", ""), uid))
db.commit()
return jsonify({"ok": True})
@@ -823,11 +930,6 @@ def api_admin_get():
db = get_db()
rows = db.execute("SELECT schluessel, wert FROM einstellungen").fetchall()
s = {r["schluessel"]: r["wert"] for r in rows}
# Mask API token – show only last 4 chars
if s.get("claude_api_token"):
tok = s["claude_api_token"]
s["claude_api_token"] = "sk-ant-…" + tok[-6:] if len(tok) > 6 else "••••••••"
s["claude_api_token_set"] = True
# Never expose admin password or login via GET
s.pop("admin_password", None)
logo = s.get("firmen_logo", "")
@@ -845,9 +947,6 @@ def api_admin_save():
for key, val in d.items():
if key in skip:
continue
# Don't overwrite token if user sent masked placeholder
if key == "claude_api_token" and val.startswith("sk-ant-…"):
continue
db.execute(
"INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)",
(key, val)
@@ -15,6 +15,21 @@ zurücküberschrieben.
"""
CHANGELOG = [
{
"version": "1.2.0",
"datum": "2026-07-19",
"titel": "Registrierung, Rechnungen & aufgeräumtes Backend",
"punkte": [
"Neu: Registrieren-Button auf dem Login-Bildschirm – Interessenten können sich jetzt selbst ein Konto anlegen (inkl. Pflichtangabe der Rechnungsanschrift).",
"Neu registrierte Konten können die App sofort ansehen; Briefe, Leistungen und Kalender-Einträge anlegen ist erst mit aktivem Abo möglich.",
"Nutzerverwaltung im Admin-Bereich zeigt jetzt Registrierungsdatum, letzten Login, IP-Adresse und die hinterlegte Rechnungsanschrift – inkl. Bearbeiten-Funktion.",
"Admin kann jetzt direkt eine PDF-Rechnung für ein Kundenkonto erstellen.",
"Sidebar zeigt Admins nur noch die für sie relevanten Bereiche (Nutzerverwaltung im Vordergrund statt Patienten/Formulare/Leistungskatalog).",
"Impressum, Datenschutzerklärung und Nutzungsbedingungen sind jetzt im Backend editierbar und für jeden per Link unten in der Sidebar erreichbar.",
"Den ungenutzten „KI – Claude API“-Bereich im Backend entfernt.",
"Den Hinweis auf die Standard-Zugangsdaten (admin/admin) von der Login-Seite entfernt.",
],
},
{
"version": "1.1.0",
"datum": "2026-07-19",
@@ -59,6 +59,10 @@ body{
.ni i{font-size:16px;width:17px;flex-shrink:0}
.ni .nbadge{margin-left:auto;background:var(--rdbg);color:var(--rdtx);font-size:10.5px;padding:1px 7px;border-radius:9px}
.sb-foot{padding:11px 14px;border-top:1px solid var(--border);font-size:11.5px;color:var(--hint);display:flex;align-items:center;gap:6px}
.sb-legal{padding:8px 14px 0;display:flex;justify-content:center;gap:6px;font-size:11px}
.sb-legal a{color:var(--hint);text-decoration:none}
.sb-legal a:hover{color:var(--text);text-decoration:underline}
.sb-legal span{color:var(--border)}
.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)}
@@ -381,14 +385,31 @@ textarea{resize:vertical;min-height:80px}
<div class="lo-title">Pflegepilot</div>
<div class="lo-subtitle" id="lo-brand">Sicher · Lokal · DSGVO-konform</div>
</div>
<div class="fg">
<div class="fg" id="lo-login-fields">
<div class="fgrp"><label>Benutzername</label><input id="lo-user" placeholder="admin" autocomplete="username" onkeydown="if(event.key==='Enter')doLogin()"></div>
<div class="fgrp"><label>Passwort</label><input id="lo-pw" type="password" placeholder="••••••" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()"></div>
</div>
<button class="btn btn-p lo-btn" onclick="doLogin()"><i class="ti ti-login"></i>Anmelden</button>
<button class="btn btn-p lo-btn" id="lo-login-btn" onclick="doLogin()"><i class="ti ti-login"></i>Anmelden</button>
<div id="lo-err" style="color:var(--rdtx);text-align:center;font-size:12.5px;min-height:18px;margin-top:8px"></div>
<button class="btn lo-btn" style="margin-top:2px" onclick="doDemoLogin()"><i class="ti ti-eye"></i>Demo ausprobieren</button>
<div class="lo-hint"><i class="ti ti-info-circle"></i> Standard-Zugangsdaten: <strong>admin / admin</strong><br>Bitte nach dem ersten Login ändern.</div>
<button class="btn lo-btn" id="lo-demo-btn" style="margin-top:2px" onclick="doDemoLogin()"><i class="ti ti-eye"></i>Demo ausprobieren</button>
<div style="text-align:center;margin-top:10px">
<a href="#" id="lo-register-link" onclick="showRegisterForm();return false" style="font-size:12.5px;color:var(--hint);text-decoration:underline">Noch kein Konto? Jetzt registrieren</a>
</div>
<div class="fg" id="lo-register-fields" style="display:none">
<div class="fgrp"><label>Anzeigename *</label><input id="rg-name-f" placeholder="Max Muster"></div>
<div class="fgrp"><label>Benutzername *</label><input id="rg-user-f" placeholder="max.muster"></div>
<div class="fgrp"><label>Passwort * (mind. 6 Zeichen)</label><input id="rg-pw-f" type="password" placeholder="••••••"></div>
<div class="slabel mt2">Rechnungsanschrift</div>
<div class="fgrp"><label>Name / Firma *</label><input id="rg-rgname-f" placeholder="Max Muster"></div>
<div class="fgrp"><label>Straße &amp; Hausnummer *</label><input id="rg-rgstrasse-f" placeholder="Musterstraße 1"></div>
<div class="fl g8"><div class="fgrp" style="width:90px"><label>PLZ *</label><input id="rg-rgplz-f" placeholder="82256"></div>
<div class="fgrp" style="flex:1"><label>Ort *</label><input id="rg-rgort-f" placeholder="Fürstenfeldbruck"></div></div>
</div>
<button class="btn btn-p lo-btn" id="lo-register-btn" style="display:none" onclick="doRegister()"><i class="ti ti-user-plus"></i>Registrieren</button>
<div id="lo-register-hint" class="lo-hint" style="display:none"><i class="ti ti-info-circle"></i> Nach der Registrierung können Sie die App ansehen; Briefe, Leistungen und Kalender-Einträge anlegen ist erst mit aktivem Abo möglich.</div>
<div style="text-align:center;margin-top:10px;display:none" id="lo-back-link-wrap">
<a href="#" onclick="hideRegisterForm();return false" style="font-size:12.5px;color:var(--hint);text-decoration:underline">Zurück zum Login</a>
</div>
</div>
</div>
<div class="app">
@@ -400,22 +421,22 @@ textarea{resize:vertical;min-height:80px}
<nav class="sb-nav">
<div class="sb-sec">Übersicht</div>
<div class="ni on" data-p="dashboard"><i class="ti ti-layout-dashboard"></i>Dashboard</div>
<div class="sb-sec">Patienten</div>
<div class="ni" data-p="patienten"><i class="ti ti-users"></i>Patientenliste</div>
<div class="ni" data-p="neu"><i class="ti ti-user-plus"></i>Neuer Patient</div>
<div class="ni" data-p="tagebuch"><i class="ti ti-notebook"></i>Pflegetagebuch<span class="nbadge" id="tb-reminder-badge" style="display:none">!</span></div>
<div class="sb-sec">Leistungskatalog</div>
<div class="ni" data-p="katalog"><i class="ti ti-list-details"></i>Alle 82 Leistungen</div>
<div class="ni" data-p="rechner"><i class="ti ti-calculator"></i>Leistungsrechner</div>
<div class="sb-sec">Formulare &amp; Recht</div>
<div class="ni" data-p="antrag"><i class="ti ti-file-text"></i>Antrag Pflegegrad</div>
<div class="ni" data-p="widerspruch"><i class="ti ti-gavel"></i>Widerspruch-Generator</div>
<div class="ni" data-p="musterbrief"><i class="ti ti-mail"></i>Musterbrief</div>
<div class="sb-sec">Aufgaben &amp; Fristen</div>
<div class="ni" data-p="aufgaben"><i class="ti ti-bell"></i>Offene Aufgaben<span class="nbadge" id="abadge">–</span></div>
<div class="ni" data-p="kalender"><i class="ti ti-calendar-event"></i>Fristen &amp; Kalender<span class="nbadge" id="kbadge" style="display:none">!</span></div>
<div class="sb-sec" data-role="nutzer">Patienten</div>
<div class="ni" data-p="patienten" data-role="nutzer"><i class="ti ti-users"></i>Patientenliste</div>
<div class="ni" data-p="neu" data-role="nutzer"><i class="ti ti-user-plus"></i>Neuer Patient</div>
<div class="ni" data-p="tagebuch" data-role="nutzer"><i class="ti ti-notebook"></i>Pflegetagebuch<span class="nbadge" id="tb-reminder-badge" style="display:none">!</span></div>
<div class="sb-sec" data-role="nutzer">Leistungskatalog</div>
<div class="ni" data-p="katalog" data-role="nutzer"><i class="ti ti-list-details"></i>Alle 82 Leistungen</div>
<div class="ni" data-p="rechner" data-role="nutzer"><i class="ti ti-calculator"></i>Leistungsrechner</div>
<div class="sb-sec" data-role="nutzer">Formulare &amp; Recht</div>
<div class="ni" data-p="antrag" data-role="nutzer"><i class="ti ti-file-text"></i>Antrag Pflegegrad</div>
<div class="ni" data-p="widerspruch" data-role="nutzer"><i class="ti ti-gavel"></i>Widerspruch-Generator</div>
<div class="ni" data-p="musterbrief" data-role="nutzer"><i class="ti ti-mail"></i>Musterbrief</div>
<div class="sb-sec" data-role="nutzer">Aufgaben &amp; Fristen</div>
<div class="ni" data-p="aufgaben" data-role="nutzer"><i class="ti ti-bell"></i>Offene Aufgaben<span class="nbadge" id="abadge">–</span></div>
<div class="ni" data-p="kalender" data-role="nutzer"><i class="ti ti-calendar-event"></i>Fristen &amp; Kalender<span class="nbadge" id="kbadge" style="display:none">!</span></div>
<div class="sb-sec">Konto</div>
<div class="ni" data-p="meinabo"><i class="ti ti-credit-card"></i>Mein Abo</div>
<div class="ni" data-p="meinabo" data-role="nutzer"><i class="ti ti-credit-card"></i>Mein Abo</div>
<div class="ni" data-p="changelog"><i class="ti ti-history"></i>Was ist neu?<span class="nbadge" id="cl-badge" style="display:none">!</span></div>
</nav>
<div class="sb-admin">
@@ -429,6 +450,13 @@ textarea{resize:vertical;min-height:80px}
</div>
<button class="btn btn-sm btn-ico" onclick="doLogout()" title="Abmelden"><i class="ti ti-logout"></i></button>
</div>
<div class="sb-legal">
<a href="#" onclick="go('impressum');return false">Impressum</a>
<span>·</span>
<a href="#" onclick="go('datenschutz');return false">Datenschutz</a>
<span>·</span>
<a href="#" onclick="go('agb');return false">AGB</a>
</div>
<div class="sb-foot"><i class="ti ti-shield-lock" style="color:var(--green)"></i>Alle Daten lokal · SQLite</div>
</div>
<div class="main">
@@ -476,14 +504,18 @@ function closeSidebar(){
function go(name,extra){
// Abo-Gate: solange kein aktiver Zugang besteht, landet jede Navigation
// auf der Abo-Seite statt auf dem eigentlichen Ziel (siehe initApp()).
if(aboBlockiert&&name!=='meinabo')name='meinabo';
// Rechtstexte bleiben davon ausgenommen - Impressum/Datenschutz duerfen nie
// hinter einer Zahlungssperre verschwinden.
const rechtstextSeiten=['impressum','datenschutz','agb'];
if(aboBlockiert&&name!=='meinabo'&&!rechtstextSeiten.includes(name))name='meinabo';
curPage=name;
document.querySelectorAll('.ni').forEach(e=>e.classList.remove('on'));
const el=document.querySelector('.ni[data-p="'+name+'"]');
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,admindash:pgAdminDash,meinabo:pgMeinAbo,changelog:pgChangelog})[name]?.(extra);
kalender:pgKalender,backend:pgBackend,admindash:pgAdminDash,meinabo:pgMeinAbo,changelog:pgChangelog,
impressum:()=>pgRechtstext('impressum'),datenschutz:()=>pgRechtstext('datenschutz'),agb:()=>pgRechtstext('agb')})[name]?.(extra);
}
async function api(path,opts={}){
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});
@@ -496,6 +528,14 @@ function fdate(d){if(!d)return'–';try{return new Date(d).toLocaleDateString('d
function toFilename(s){return(s||'').replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue').replace(/ß/g,'ss').replace(/[^a-zA-Z0-9]+/g,'_').replace(/^_|_$/g,'');}
function isoDate(){return new Date().toISOString().slice(0,10);}
function toast(m,ok=true){const t=document.createElement('div');t.className='toast';t.innerHTML='<i class="ti ti-'+(ok?'check':'alert-triangle')+'"></i>'+m;document.body.appendChild(t);setTimeout(()=>t.remove(),2800);}
function _schreibgesperrt(){
if(curUser&&curUser.schreibzugriff_gesperrt){
toast('Diese Funktion erfordert ein aktives Abo – bitte zuerst freischalten.',false);
go('meinabo');
return true;
}
return false;
}
function escH(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Responsive Tabellen-Akkordeon (Mobile/Tablet): erste Karte offen, Rest
// zugeklappt/gestapelt; Klick auf eine Karte öffnet sie und schliesst die
@@ -731,6 +771,7 @@ async function showLById(id){
if(item)showL(item);
}
async function assignL(lid){
if(_schreibgesperrt())return;
if(!katPid){toast('Kein Patient ausgewählt',false);return;}
const r=await api('/api/patienten/'+katPid+'/leistungen/katalog',{method:'POST',body:JSON.stringify({katalog_id:lid})});
if(r.already){toast('Leistung bereits zugewiesen',false);return;}
@@ -1160,6 +1201,11 @@ async function initApp(){
// Admin-nav nur für admin sichtbar
const adm=document.querySelector('.sb-admin');
if(adm)adm.style.display=me.rolle==='admin'?'':'none';
// Patienten-/Formulare-/Aufgaben-Navigation ist für Admin irrelevant (kein
// eigenes Patientengut) - im Vordergrund steht stattdessen die Nutzerverwaltung.
document.querySelectorAll('[data-role="nutzer"]').forEach(el=>{
el.style.display=me.rolle==='admin'?'none':'';
});
// Update-Hinweis: einmalig nach einem Update anzeigen, bis "Gelesen" geklickt wird.
// Waehrend eines Abo-Sperre keine Ablenkung - kommt beim naechsten Login dran.
const clBadge=document.getElementById('cl-badge');
@@ -1190,7 +1236,7 @@ async function doLogin(){
document.getElementById('lo-pw').value='';
await initApp();
refreshBadge();
go('dashboard');
go(r.rolle==='admin'?'admindash':'dashboard');
} else {
if(err)err.textContent=r.error||'Anmeldung fehlgeschlagen';
}
@@ -1200,6 +1246,48 @@ async function doDemoLogin(){
document.getElementById('lo-pw').value='demo';
await doLogin();
}
function showRegisterForm(){
document.getElementById('lo-login-fields').style.display='none';
document.getElementById('lo-login-btn').style.display='none';
document.getElementById('lo-demo-btn').style.display='none';
document.getElementById('lo-register-link').parentElement.style.display='none';
document.getElementById('lo-register-fields').style.display='';
document.getElementById('lo-register-btn').style.display='';
document.getElementById('lo-register-hint').style.display='';
document.getElementById('lo-back-link-wrap').style.display='';
document.getElementById('lo-err').textContent='';
}
function hideRegisterForm(){
document.getElementById('lo-login-fields').style.display='';
document.getElementById('lo-login-btn').style.display='';
document.getElementById('lo-demo-btn').style.display='';
document.getElementById('lo-register-link').parentElement.style.display='';
document.getElementById('lo-register-fields').style.display='none';
document.getElementById('lo-register-btn').style.display='none';
document.getElementById('lo-register-hint').style.display='none';
document.getElementById('lo-back-link-wrap').style.display='none';
document.getElementById('lo-err').textContent='';
}
async function doRegister(){
const g=id=>document.getElementById(id)?.value.trim()||'';
const err=document.getElementById('lo-err');
const payload={
name:g('rg-name-f'),benutzername:g('rg-user-f'),passwort:g('rg-pw-f'),
rg_name:g('rg-rgname-f'),rg_strasse:g('rg-rgstrasse-f'),rg_plz:g('rg-rgplz-f'),rg_ort:g('rg-rgort-f')
};
if(!payload.name||!payload.benutzername||!payload.passwort||!payload.rg_name||!payload.rg_strasse||!payload.rg_plz||!payload.rg_ort){
if(err)err.textContent='Bitte alle Pflichtfelder (*) ausfüllen';
return;
}
const r=await fetch('/api/auth/registrieren',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(x=>x.json()).catch(()=>({error:'Verbindungsfehler'}));
if(r.ok){
await initApp();
refreshBadge();
go('dashboard');
} else {
if(err)err.textContent=r.error||'Registrierung fehlgeschlagen';
}
}
async function doLogout(){
await fetch('/api/auth/logout',{method:'POST'});
curUser=null;
@@ -1240,6 +1328,20 @@ async function aboJetztBezahlen(){
toast(r.error||'Zahlung folgt in Kürze',false);
}
// ── Rechtstexte (Impressum / Datenschutz / Nutzungsbedingungen) ──────────
const RECHTSTEXT_TITEL={impressum:'Impressum',datenschutz:'Datenschutzerklärung',agb:'Nutzungsbedingungen'};
async function pgRechtstext(typ){
const titel=RECHTSTEXT_TITEL[typ]||'Rechtstext';
if(!Object.keys(_einstellungen).length){
try{const s=await api('/api/einstellungen/public');_einstellungen={..._einstellungen,...s};}catch(e){}
}
const text=_einstellungen['rechtstext_'+typ];
R('<div class="card" style="max-width:720px">'+
(text?'<div style="white-space:pre-wrap;line-height:1.7">'+escH(text)+'</div>'
:'<p class="muted">Noch kein Text hinterlegt.</p>')+
'</div>',titel);
}
// ── Update-Hinweis / Changelog ────────────────────────────
let clEntriesCache=[];
function clEntriesHtml(eintraege,adminMode){
@@ -1348,17 +1450,17 @@ async function pgBackend(){
'<div class="fl g8" style="justify-content:flex-end">'+
'<button class="btn btn-p" onclick="saveEinst([\'firmenname\',\'firmen_strasse\',\'firmen_plz\',\'firmen_ort\',\'firmen_tel\',\'firmen_email\'])"><i class="ti ti-check"></i>Speichern</button></div>'+
'</div>'+
// KI
// Rechtstexte
'<div class="card">'+
'<div class="ch"><span class="ct">KI – Claude API</span>'+
(s.claude_api_token_set?'<span class="badge b-g"><i class="ti ti-circle-check"></i> Aktiv</span>':'<span class="badge b-gr">Nicht konfiguriert</span>')+'</div>'+
'<div class="alert a-info mb4"><i class="ti ti-sparkles"></i><p>API-Token für zukünftige KI-Funktionen (Widerspruch-Vorschläge, Zusammenfassungen). Format: sk-ant-…</p></div>'+
'<div class="fg mb4"><div class="fgrp"><label>Claude API-Token</label>'+
'<div class="api-token-wrap"><input id="e-claude_api_token" type="password" value="'+(s.claude_api_token||'')+'" placeholder="sk-ant-api03-…">'+
'<button onclick="var i=document.getElementById(\'e-claude_api_token\');i.type=i.type===\'password\'?\'text\':\'password\'"><i class="ti ti-eye"></i></button></div>'+
'<div class="muted sm mt2">Token unter console.anthropic.com erstellen</div></div></div>'+
'<div class="ch"><span class="ct">Rechtstexte</span></div>'+
'<div class="alert a-info mb4"><i class="ti ti-scale"></i><p>Wird Kunden per 1-Klick-Link unten in der Sidebar angezeigt. Einfacher Text, Zeilenumbrüche werden übernommen.</p></div>'+
'<div class="fg mb4">'+
'<div class="fgrp"><label>Impressum</label><textarea id="e-rechtstext_impressum" rows="4" placeholder="Angaben gemäß § 5 TMG …">'+(s.rechtstext_impressum||'')+'</textarea></div>'+
'<div class="fgrp"><label>Datenschutzerklärung</label><textarea id="e-rechtstext_datenschutz" rows="4" placeholder="Datenschutzerklärung …">'+(s.rechtstext_datenschutz||'')+'</textarea></div>'+
'<div class="fgrp"><label>Nutzungsbedingungen</label><textarea id="e-rechtstext_agb" rows="4" placeholder="Nutzungsbedingungen …">'+(s.rechtstext_agb||'')+'</textarea></div>'+
'</div>'+
'<div class="fl g8" style="justify-content:flex-end">'+
'<button class="btn btn-p" onclick="saveEinst([\'claude_api_token\'])"><i class="ti ti-check"></i>Speichern</button></div>'+
'<button class="btn btn-p" onclick="saveEinst([\'rechtstext_impressum\',\'rechtstext_datenschutz\',\'rechtstext_agb\'])"><i class="ti ti-check"></i>Speichern</button></div>'+
'</div>'+
'</div>'+
@@ -1468,7 +1570,9 @@ async function pgAdminDash(){
]);
renderAdminDash(nutzer,log,patienten);
}
let adminNutzerCache=[];
function renderAdminDash(nutzer,log,patienten){
adminNutzerCache=nutzer||[];
const rows=(nutzer||[]).map(n=>{
const gesperrt=!n.aktiv;
const isSelf=n.id===curUser?.id;
@@ -1482,13 +1586,17 @@ function renderAdminDash(nutzer,log,patienten){
'<td data-label="Status">'+(gesperrt?'<span class="badge b-r">Gesperrt</span>':'<span class="badge b-g">Aktiv</span>')+'</td>'+
'<td data-label="Patienten">'+n.patienten_anzahl+'</td>'+
'<td data-label="Abo">'+aboText+'</td>'+
'<td data-label="Registriert"><div class="sm">'+fdate(n.erstellt_am)+'</div></td>'+
'<td data-label="Letzter Login"><div class="sm">'+(n.letzter_login?fdate(n.letzter_login):'–')+'</div><div class="sm muted">'+(n.letzte_ip||'')+'</div></td>'+
'<td>'+(isSelf?'<span class="sm muted">Eigenes Konto</span>':
'<div class="fl g8" style="flex-wrap:wrap">'+
'<button class="btn btn-sm" onclick="adminNutzerBearbeitenModal('+n.id+')"><i class="ti ti-edit"></i>Bearbeiten</button>'+
(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>'
)+
(n.rolle!=='admin'?'<button class="btn btn-sm" onclick="adminAboModal('+n.id+',\''+escH(n.name||n.benutzername)+'\')"><i class="ti ti-gift"></i>Freischalten</button>':'')+
(n.rolle!=='admin'?'<button class="btn btn-sm" onclick="rechnungModal('+n.id+')"><i class="ti ti-receipt"></i>Rechnung</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>';
@@ -1508,7 +1616,7 @@ function renderAdminDash(nutzer,log,patienten){
:'<div class="muted sm">Noch keine Einträge.</div>';
R('<div class="card mb4">'+
'<div class="slabel mb3">Konten</div>'+
'<div class="tw rtable"><table><thead><tr><th></th><th>Name</th><th>Rolle</th><th>Status</th><th>Patienten</th><th>Abo</th><th>Aktionen</th></tr></thead>'+
'<div class="tw rtable"><table><thead><tr><th></th><th>Name</th><th>Rolle</th><th>Status</th><th>Patienten</th><th>Abo</th><th>Registriert</th><th>Letzter Login</th><th>Aktionen</th></tr></thead>'+
'<tbody>'+rows+'</tbody></table></div>'+
'</div>'+
'<div class="card mb4">'+
@@ -1572,6 +1680,98 @@ async function adminAboFreischalten(uid){
toast('Abo freigeschaltet bis '+fdate(r.abo_bis));
pgAdminDash();
}
function adminNutzerBearbeitenModal(uid){
const n=adminNutzerCache.find(x=>x.id===uid);
if(!n)return;
OM('Konto bearbeiten – '+escH(n.name||n.benutzername),
'<div class="fg">'+
'<div class="fgrp"><label>Anzeigename</label><input id="ab-name" value="'+escH(n.name||'')+'"></div>'+
'<div class="fgrp"><label>Rolle</label><select id="ab-rolle"><option value="nutzer"'+(n.rolle==='nutzer'?' selected':'')+'>Nutzer</option><option value="admin"'+(n.rolle==='admin'?' selected':'')+'>Administrator</option></select></div>'+
'<div class="fgrp"><label>Status</label><select id="ab-aktiv"><option value="1"'+(n.aktiv?' selected':'')+'>Aktiv</option><option value="0"'+(!n.aktiv?' selected':'')+'>Inaktiv</option></select></div>'+
'<div class="fgrp"><label>Neues Passwort (leer = unverändert)</label><input id="ab-pw" type="password" placeholder="Neues Passwort …"></div>'+
'</div>'+
'<div class="slabel mt3 mb2">Rechnungsanschrift</div>'+
'<div class="fg c2">'+
'<div class="fgrp" style="grid-column:span 2"><label>Name / Firma</label><input id="ab-rg-name" value="'+escH(n.rg_name||'')+'"></div>'+
'<div class="fgrp" style="grid-column:span 2"><label>Straße &amp; Hausnummer</label><input id="ab-rg-strasse" value="'+escH(n.rg_strasse||'')+'"></div>'+
'<div class="fgrp"><label>PLZ</label><input id="ab-rg-plz" value="'+escH(n.rg_plz||'')+'"></div>'+
'<div class="fgrp"><label>Ort</label><input id="ab-rg-ort" value="'+escH(n.rg_ort||'')+'"></div>'+
'</div>'+
'<div class="fl g8 mt3" style="justify-content:flex-end">'+
'<button class="btn" onclick="CM()">Abbrechen</button>'+
'<button class="btn btn-p" onclick="adminNutzerBearbeitenSpeichern('+uid+')"><i class="ti ti-check"></i>Speichern</button></div>'
);
}
async function adminNutzerBearbeitenSpeichern(uid){
const g=id=>document.getElementById(id)?.value||'';
const r=await api('/api/nutzer/'+uid,{method:'PUT',body:JSON.stringify({
name:g('ab-name'),rolle:g('ab-rolle'),aktiv:g('ab-aktiv'),passwort:g('ab-pw'),
rg_name:g('ab-rg-name'),rg_strasse:g('ab-rg-strasse'),rg_plz:g('ab-rg-plz'),rg_ort:g('ab-rg-ort')
})});
if(r.ok){CM();toast('Konto aktualisiert ✓');pgAdminDash();}
else toast(r.error||'Fehler',false);
}
// ── Rechnungserstellung (PDF, client-seitig wie Musterbrief/Widerspruch) ───
function rechnungModal(uid){
const n=adminNutzerCache.find(x=>x.id===uid);
if(!n)return;
const heute=new Date();
const nr='RE-'+heute.getFullYear()+String(heute.getMonth()+1).padStart(2,'0')+'-'+uid;
const zeitraum=heute.toLocaleDateString('de-DE',{month:'long',year:'numeric'});
OM('Rechnung erstellen – '+escH(n.name||n.benutzername),
'<div class="fg c2">'+
'<div class="fgrp"><label>Rechnungsnummer</label><input id="rg-nr" value="'+nr+'"></div>'+
'<div class="fgrp"><label>Zeitraum</label><input id="rg-zeitraum" value="'+zeitraum+'"></div>'+
'<div class="fgrp"><label>Betrag (EUR)</label><input id="rg-betrag" value="4.99"></div>'+
'<div class="fgrp"><label>Rechnungsdatum</label><input id="rg-datum" type="date" value="'+isoDate()+'"></div>'+
'<div class="fgrp" style="grid-column:span 2"><label>Empfänger Name/Firma</label><input id="rg-name" value="'+escH(n.rg_name||n.name||'')+'"></div>'+
'<div class="fgrp" style="grid-column:span 2"><label>Straße &amp; Hausnummer</label><input id="rg-strasse" value="'+escH(n.rg_strasse||'')+'"></div>'+
'<div class="fgrp"><label>PLZ</label><input id="rg-plz" value="'+escH(n.rg_plz||'')+'"></div>'+
'<div class="fgrp"><label>Ort</label><input id="rg-ort" value="'+escH(n.rg_ort||'')+'"></div>'+
'</div>'+
(n.rg_name?'':'<div class="alert a-warn mb3"><i class="ti ti-alert-triangle"></i><p>Keine Rechnungsanschrift hinterlegt – bitte vor dem Erstellen ergänzen.</p></div>')+
'<div class="fl g8 mt3" style="justify-content:flex-end">'+
'<button class="btn" onclick="CM()">Abbrechen</button>'+
'<button class="btn btn-p" onclick="rechnungErstellen()"><i class="ti ti-file-download"></i>PDF erstellen</button></div>'
);
}
function rechnungErstellen(){
const g=id=>document.getElementById(id)?.value||'';
const nr=g('rg-nr'),zeitraum=g('rg-zeitraum'),betrag=g('rg-betrag'),datum=g('rg-datum');
const eName=g('rg-name'),eStr=g('rg-strasse'),ePlz=g('rg-plz'),eOrt=g('rg-ort');
if(!eName||!eStr||!ePlz||!eOrt){toast('Bitte Rechnungsanschrift vollständig ausfüllen',false);return;}
const firma=_einstellungen.firmenname||'Pflegepilot';
const absender=[firma,_einstellungen.firmen_strasse,(_einstellungen.firmen_plz||'')+' '+(_einstellungen.firmen_ort||''),_einstellungen.firmen_tel?('Tel.: '+_einstellungen.firmen_tel):''].filter(Boolean).join('\n');
const text=
absender+'\n\n\n'+
eName+'\n'+eStr+'\n'+ePlz+' '+eOrt+'\n\n\n'+
'Rechnungsnummer: '+nr+'\n'+
'Rechnungsdatum: '+fdate(datum)+'\n\n'+
'Rechnung für Pflegepilot-Nutzung – '+zeitraum+'\n'+
'───────────────────────────────────────────\n'+
'Pos. Beschreibung Betrag\n'+
'1 Pflegepilot Monatsabo, '+zeitraum+' '+betrag+' EUR\n'+
'───────────────────────────────────────────\n'+
'Gesamtbetrag: '+betrag+' EUR\n\n\n'+
'Vielen Dank für Ihr Vertrauen.\n\n'+
'Mit freundlichen Grüßen\n'+firma;
const el=document.createElement('div');
el.style.cssText='font-family:"Courier New",Courier,monospace;font-size:10.5pt;line-height:1.7;padding:0;white-space:pre-wrap;color:#111;background:#fff;word-break:break-word;';
el.textContent=text;
const fname='Rechnung_'+toFilename(nr)+'.pdf';
html2pdf().set({margin:[25,20,28,25],filename:fname,html2canvas:{scale:2,useCORS:true},jsPDF:{unit:'mm',format:'a4',orientation:'portrait'}})
.from(el).toPdf().get('pdf').then(pdf=>{
addPageNums(pdf);
const blob=pdf.output('blob');
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=fname;
document.body.appendChild(a);a.click();document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(url),2000);
toast('Rechnung heruntergeladen ✓');
CM();
});
}
async function dsgvoExport(){
const pid=document.getElementById('dsgvo-pid')?.value;
const grund=(document.getElementById('dsgvo-grund')?.value||'').trim();
@@ -2462,6 +2662,7 @@ async function sendMailModal(typ){
}
async function sendMail(typ){
if(_schreibgesperrt())return;
const to=(document.getElementById('ml-to')?.value||'').trim();
const subj=document.getElementById('ml-subj')?.value||'';
const body=document.getElementById('ml-body')?.value||'';
@@ -2510,6 +2711,7 @@ async function sendMail(typ){
}
function dlW(){
if(_schreibgesperrt())return;
const t=document.getElementById('wpreview')?.textContent||'';
if(!t.trim()){toast('Kein Text zum Exportieren',false);return;}
const el=document.createElement('div');
@@ -2616,6 +2818,7 @@ function updM(){
}
function cpM(){navigator.clipboard.writeText(document.getElementById('mpreview')?.textContent||'');toast('Kopiert');}
function dlM(){
if(_schreibgesperrt())return;
const t=document.getElementById('mpreview')?.textContent||'';
if(!t.trim()){toast('Kein Text zum Exportieren',false);return;}
const el=document.createElement('div');
@@ -2857,13 +3060,15 @@ function newAM(){
'<button class="btn btn-p" onclick="saveAufg()"><i class="ti ti-check"></i>Speichern</button></div>');
}
async function saveAufg(){
if(_schreibgesperrt())return;
const g=id=>document.getElementById(id)?.value||'';
if(!g('nat')){toast('Titel eingeben',false);return;}
await api('/api/aufgaben',{method:'POST',body:JSON.stringify({titel:g('nat'),beschreibung:g('nad'),prioritaet:g('nap'),faellig_am:g('naf')})});
const r=await api('/api/aufgaben',{method:'POST',body:JSON.stringify({titel:g('nat'),beschreibung:g('nad'),prioritaet:g('nap'),faellig_am:g('naf')})});
if(r.error){toast(r.error,false);return;}
CM();toast('Aufgabe angelegt');refreshBadge();if(curPage==='aufgaben')pgAufgaben();
}
initApp().then(()=>{if(curUser){refreshBadge();go('dashboard');}});
initApp().then(()=>{if(curUser){refreshBadge();go(curUser.rolle==='admin'?'admindash':'dashboard');}});
</script>
</body>
</html>