Responsive/warme Neugestaltung + Abo-Zahlungsgeruest (4,99EUR/Monat)

Responsive & Design:
- Sidebar wird unter 900px zum Off-Canvas-Menue (Hamburger-Button,
  Backdrop, Auto-Close bei Navigation) statt fix 228px breit zu bleiben
- Formular-/Statistik-Grids brechen bei 900px/560px auf weniger Spalten
  um, Modals gehen unter 560px auf Vollbild, iOS-Zoom-Bug bei
  Formularfeldern durch font-size:16px auf Mobile vermieden
- Wärmere Optik ausschliesslich über CSS-Variablen (:root): Creme-
  Hintergrund, weichere Akzentfarbe, groessere Radien, neuer
  --shadow-Token auf Card/Modal - kein Redesign pro Seite noetig, da
  die App durchgehend über diese Variablen themt

Abo-/Zahlungsgeruest (Anbieter voraussichtlich PayPal, noch nicht
angebunden):
- nutzer.abo_bis / abo_typ + globaler Schalter einstellungen.abo_pflicht
  (Default AUS) - solange PayPal nicht wirklich läuft, sperrt das Gate
  niemanden aus
- _hat_aktiven_zugang() zentral in global_auth_check(): blockiert bei
  aktiver Zahlungspflicht ohne gueltiges Abo mit 402 Payment Required,
  laesst /api/abo/* und /api/auth/logout immer durch
- POST /api/abo/aktivieren ist bewusst ein 501-Stub bis PayPal echt
  angebunden ist - kein vorgetaeuschter Erfolg
- Admin-Dashboard: "Freischalten"-Aktion pro Konto (1-12 Monate,
  Pflicht-Begruendung, audit-geloggt) - deckt die geforderte
  Moeglichkeit ab, Konten kostenlos freizuschalten
  (POST /api/admin/nutzer/<uid>/abo-freischalten)
- Backend-Einstellungen: neue Karte "Abo & Zahlung" fuer Preis und
  den abo_pflicht-Schalter
  - Frontend: neue Seite "Mein Abo" (pgMeinAbo), aboBlockiert-Flag in
  initApp() leitet jede Navigation dorthin um, wenn kein Zugang besteht

Verifiziert: JS-Syntax-Check, Playwright-Screenshots auf Desktop/
Tablet/Mobile (Sidebar-Slide-in, Grid-Umbruch, Interview-Wizard auf
Handy), API-Tests fuer das komplette Abo-Gate (Toggle an/aus, 402,
Freischaltung, Stub-Endpoint, Restore auf sicheren Default).
This commit is contained in:
admin committed 2026-07-18 11:26:57 +02:00
1 parent eb57a6eccf
commit 0884f02e15
3 files changed
+291 -23

No files matched your search

+21 -1
View File
@@ -12,7 +12,7 @@ This is not a single project but a working directory with one active app plus re
- **`Anleitung.md`, `Leistungen.md`, `Leistungen2.md`, `Pflegegrad.md`, `Musterbrief.md`** — German-language domain reference notes (care-benefits catalog research, sample letters, project rationale) used as source material for `katalog.py` and the letter generators. Not code.
- **`refactoring.md`** — the current architecture-change brief from the product owner (see "Planned architecture pivot" below). Read this before any structural work.
No git repository is initialized in this directory.
A git repository is initialized at the root (`Pflegepilot/.git`). `.gitignore` excludes the SQLite DB, patient-generated files (`static/briefe`, `static/dokumente`, `static/fotos`), `__pycache__`, and `graphify-out/` — never commit real patient data.
## Commands
@@ -62,6 +62,26 @@ The product owner specified a pivot away from the original **central multi-patie
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).
## Responsive design & visual style
The frontend was originally desktop-only (zero `@media` queries, fixed 228px sidebar, fixed-column form grids) and visually "clinical" per user feedback. Both were addressed together in `templates/index.html`:
- **Mobile/tablet layout**: below 900px the `.sb` sidebar becomes a fixed off-canvas panel (`transform:translateX(-100%)`, slides in via `.sb.open`), toggled by a hamburger button (`#sb-toggle`, only visible in that breakpoint) and a `#sb-backdrop` overlay that closes it on tap. `toggleSidebar()`/`closeSidebar()` in the `<script>` block manage this; every nav-item click also auto-closes it. Form grids (`.fg.c2/.c3`) and stat grids (`.g4`) collapse to fewer columns at 900px and 560px. Modals go full-screen below 560px. Form inputs are forced to `font-size:16px` on phones specifically to stop iOS Safari's auto-zoom-on-focus.
- **Visual tone**: warmed up via CSS custom-property changes only (`:root` block) — cream background/surface instead of gray-beige, softened green accent, larger border radii (`--r`/`--rl`), and a new `--shadow` token applied to `.card`/`.modal` for subtle depth. Because the whole app already themes through these variables, this was a low-risk, high-leverage change rather than a per-page rewrite.
- **Verification gotcha**: `.content` scrolls internally (`overflow-y:auto` inside a `height:100vh` shell), so Playwright's `page.screenshot({fullPage:true})` does **not** capture content below the fold — it only captures the viewport. To see the rest, scroll `.content` itself (`document.querySelector('.content').scrollTop = ...`) before screenshotting.
## Subscription / payment scaffolding
The product owner wants the app to cost **4.99 €/month per account**, provider likely **PayPal** (not yet integrated — no account/API keys exist yet). Since there's no live payment method, the paywall is built but wired to a **global off switch** so it can't lock anyone out before PayPal actually works:
- `einstellungen` key `abo_pflicht` (`"1"`/`"0"`, default off) — read via `_abo_pflicht_aktiv()`. Toggle lives in the Backend-Einstellungen "Abo & Zahlung" card. **Leave this off until PayPal is actually wired up.**
- `nutzer.abo_bis` (date, NULL = never granted) / `nutzer.abo_typ` (`'kostenlos_admin'` | future `'paypal'`) track access per account. `_hat_aktiven_zugang(u)` is the single source of truth: always true for admins, always true while `abo_pflicht` is off, otherwise `abo_bis >= today`.
- `global_auth_check()` enforces this on every `/api/*` call except a small allowlist (`/api/abo/status`, `/api/abo/aktivieren`, `/api/auth/logout`) — returns **402 Payment Required** (`code: "abo_erforderlich"`) when blocked, so a blocked user can still see their status and (eventually) pay.
- `POST /api/abo/aktivieren` is an intentional **stub** (501, "noch nicht verfügbar") — real PayPal Checkout/Subscriptions integration replaces this later; do not fake a success response here.
- Admin can grant free access via `POST /api/admin/nutzer/<uid>/abo-freischalten` (`{monate, begruendung}`, 1–12 months, extends from the later of today/existing `abo_bis`, admin-only, audit-logged like the other admin-dashboard actions) — this is the "1 Monat kostenlos freischalten" capability the operator asked for, surfaced as a "Freischalten" button per row in the Admin-Dashboard.
- Frontend: `curAbo`/`aboBlockiert` globals are set in `initApp()` from `GET /api/abo/status`. `go(name)` redirects any navigation to the `meinabo` page while `aboBlockiert` is true, so a blocked account always lands on its own status/pay screen instead of a broken page. `pgMeinAbo()` is that page (status, price, stub PayPal button) and doubles as the always-available "check my subscription" view when unblocked.
- When actually integrating PayPal: replace `/api/abo/aktivieren`'s stub body with real Checkout/Subscription creation, add a webhook endpoint to update `abo_bis`/`abo_typ` on payment events, and only then flip `abo_pflicht` to `"1"`.
## 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.
+122 -3
View File
@@ -60,6 +60,8 @@ 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)",
"ALTER TABLE nutzer ADD COLUMN abo_bis TEXT",
"ALTER TABLE nutzer ADD COLUMN abo_typ TEXT",
"""CREATE TABLE IF NOT EXISTS admin_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
@@ -129,7 +131,7 @@ def _get_current_user():
return None
try:
return get_db().execute(
"SELECT id, benutzername, name, rolle FROM nutzer WHERE id=? AND aktiv=1", (uid,)
"SELECT id, benutzername, name, rolle, abo_bis, abo_typ FROM nutzer WHERE id=? AND aktiv=1", (uid,)
).fetchone()
except Exception:
return None
@@ -199,6 +201,39 @@ def _archiviere_patient(db, pid, status, hinweis, status_datum=None):
""", (status, status_datum, archiv_frist, hinweis, pid))
return archiv_frist, jahre
# ─── Abo / Zahlungspflicht ────────────────────────────────────────────────
# Geruest fuer die 4,99 €/Monat-Abrechnung (Zahlungsanbieter voraussichtlich
# PayPal, noch nicht angebunden). abo_pflicht ist ein globaler Schalter in
# der einstellungen-Tabelle, standardmäßig AUS: solange kein echter
# Zahlungsweg existiert, darf ein scharf geschaltetes Gate niemanden
# aussperren. Erst wenn PayPal wirklich läuft, wird der Schalter umgelegt.
def _abo_pflicht_aktiv():
return get_setting("abo_pflicht", "0") == "1"
def _hat_aktiven_zugang(u):
if u is None:
return False
if u["rolle"] == "admin":
return True
if not _abo_pflicht_aktiv():
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
monat = monat % 12 + 1
schaltjahr = jahr % 4 == 0 and (jahr % 100 != 0 or jahr % 400 == 0)
tage_im_monat = [31, 29 if schaltjahr else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
tag = min(d.day, tage_im_monat[monat - 1])
return date(jahr, monat, tag)
def login_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
@@ -224,8 +259,15 @@ def global_auth_check():
public = {"/api/auth/login", "/api/auth/me", "/api/einstellungen/public"}
if path in public:
return None
if not _get_current_user():
u = _get_current_user()
if not u:
return jsonify({"error": "Nicht angemeldet"}), 401
# Abo-Gate: nur relevant, solange abo_pflicht aktiv ist (siehe _hat_aktiven_zugang).
# Auth/Abo-Endpunkte bleiben immer erreichbar, damit man Status sehen und
# (künftig) bezahlen kann, auch wenn der Zugang gerade gesperrt ist.
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
# ─── API: Öffentliche Einstellungen (Branding) ───────────────────────────────
@app.route("/api/einstellungen/public", methods=["GET"])
@@ -269,20 +311,65 @@ def api_auth_logout():
session.clear()
return jsonify({"ok": True})
# ─── API: Abo / Zahlung (Gerüst – Zahlungsanbieter noch nicht angebunden) ────
@app.route("/api/abo/status", methods=["GET"])
def api_abo_status():
u = _get_current_user()
if not u:
return jsonify({"error": "Nicht angemeldet"}), 401
abo_bis = u["abo_bis"] if "abo_bis" in u.keys() else None
tage_verbleibend = None
if abo_bis:
try:
tage_verbleibend = (datetime.strptime(abo_bis, "%Y-%m-%d").date() - date.today()).days
except Exception:
pass
return jsonify({
"zugang_aktiv": _hat_aktiven_zugang(u),
"abo_pflicht_aktiv": _abo_pflicht_aktiv(),
"abo_bis": abo_bis,
"abo_typ": u["abo_typ"] if "abo_typ" in u.keys() else None,
"tage_verbleibend": tage_verbleibend,
"preis": get_setting("abo_preis", "4.99"),
"waehrung": get_setting("abo_waehrung", "EUR"),
"ist_admin": u["rolle"] == "admin",
})
@app.route("/api/abo/aktivieren", methods=["POST"])
def api_abo_aktivieren():
# Platzhalter für die künftige PayPal-Integration (Subscriptions/Checkout).
# Absichtlich noch nicht implementiert, bis ein PayPal-Konto existiert –
# ein ehrlicher Hinweis ist besser als ein vorgetäuschter Erfolg.
return jsonify({
"error": "Online-Zahlung ist noch nicht verfügbar. Bitte wenden Sie sich an den Betreiber.",
"code": "zahlung_nicht_verfuegbar"
}), 501
# ─── API: Nutzerverwaltung (Admin) ────────────────────────────────────────────
@app.route("/api/nutzer", methods=["GET"])
@admin_required
def api_nutzer_list():
db = get_db()
rows = db.execute(
"SELECT id, benutzername, name, rolle, aktiv, erstellt_am FROM nutzer ORDER BY erstellt_am"
"SELECT id, benutzername, name, rolle, aktiv, erstellt_am, abo_bis, abo_typ FROM nutzer ORDER BY erstellt_am"
).fetchall()
pflicht = _abo_pflicht_aktiv()
heute = date.today()
result = []
for r in rows:
d = dict(r)
d["patienten_anzahl"] = db.execute(
"SELECT COUNT(*) FROM patienten WHERE nutzer_id=?", (r["id"],)
).fetchone()[0]
zugang = True
if r["rolle"] != "admin" and pflicht:
zugang = False
if r["abo_bis"]:
try:
zugang = datetime.strptime(r["abo_bis"], "%Y-%m-%d").date() >= heute
except Exception:
pass
d["zugang_aktiv"] = zugang
result.append(d)
return jsonify(result)
@@ -398,6 +485,38 @@ def api_admin_nutzer_loeschen(uid):
grund + f" ({len(patienten)} Patient(en) archiviert)")
return jsonify({"ok": True, "archivierte_patienten": len(patienten)})
@app.route("/api/admin/nutzer/<int:uid>/abo-freischalten", methods=["POST"])
@admin_required
def api_admin_abo_freischalten(uid):
d = request.json or {}
grund = (d.get("begruendung") or "").strip()
try:
monate = int(d.get("monate", 1))
except Exception:
monate = 1
monate = max(1, min(monate, 12))
if not grund:
return jsonify({"error": "Begründung erforderlich"}), 400
db = get_db()
n = db.execute("SELECT benutzername, abo_bis FROM nutzer WHERE id=?", (uid,)).fetchone()
if not n:
return jsonify({"error": "Nicht gefunden"}), 404
heute = date.today()
basis = heute
if n["abo_bis"]:
try:
bestehend = datetime.strptime(n["abo_bis"], "%Y-%m-%d").date()
if bestehend > heute:
basis = bestehend
except Exception:
pass
neues_abo_bis = _add_monate(basis, monate).isoformat()
db.execute("UPDATE nutzer SET abo_bis=?, abo_typ='kostenlos_admin' WHERE id=?", (neues_abo_bis, uid))
db.commit()
_log_admin_aktion("abo_freigeschaltet", "nutzer", uid, n["benutzername"],
f"{grund} ({monate} Monat(e) kostenlos bis {neues_abo_bis})")
return jsonify({"ok": True, "abo_bis": neues_abo_bis})
@app.route("/api/admin/audit-log", methods=["GET"])
@admin_required
def api_admin_audit_log():
@@ -10,20 +10,21 @@
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f5f4f0;--surface:#fff;--border:#e5e2d8;--border2:#ccc8bb;
--text:#1a1a18;--muted:#6b6960;--hint:#9c9990;
--green:#1D9E75;--gbg:#E1F5EE;--gdk:#085041;--gmd:#0F6E56;
--ambg:#FAEEDA;--amtx:#854F0B;
--blbg:#E6F1FB;--bltx:#185FA5;
--rdbg:#FCEBEB;--rdtx:#A32D2D;
--grbg:#F1EFE8;--grtx:#5F5E5A;
--pubg:#EEEDFE;--putx:#3C3489;
--cobg:#FAECE7;--cotx:#993C1D;
--r:8px;--rl:12px;
--bg:#f8f4ec;--surface:#fffdfb;--border:#ece3d3;--border2:#ddccb2;
--text:#2b2620;--muted:#78695a;--hint:#a89a86;
--green:#20976F;--gbg:#E4F4EC;--gdk:#0B5A45;--gmd:#12795C;
--ambg:#FBF0DC;--amtx:#8A5416;
--blbg:#E9F1FB;--bltx:#1E5FA0;
--rdbg:#FCEBEA;--rdtx:#AB3327;
--grbg:#F2EEE3;--grtx:#665B4C;
--pubg:#EFEDFC;--putx:#453C99;
--cobg:#FBECE3;--cotx:#9C4420;
--r:10px;--rl:16px;
--shadow:0 1px 2px rgba(43,34,20,.05),0 4px 14px rgba(43,34,20,.055);
}
body{font-family:'IBM Plex Sans',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
.app{display:flex;height:100vh;overflow:hidden}
.sb{width:228px;min-width:228px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sb{width:228px;min-width:228px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;transition:transform .22s ease}
.sb-logo{padding:16px;border-bottom:1px solid var(--border)}
.sb-name{font-size:15px;font-weight:500;display:flex;align-items:center;gap:7px}
.sb-name i{color:var(--green);font-size:19px}
@@ -91,13 +92,13 @@ body{font-family:'IBM Plex Sans',sans-serif;background:var(--bg);color:var(--tex
.btn-p:hover{background:var(--gmd)}
.btn-sm{padding:5px 10px;font-size:12px}
.btn-ico{padding:6px 8px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);padding:15px 17px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--rl);padding:18px 20px;box-shadow:var(--shadow)}
.ch{display:flex;align-items:center;justify-content:space-between;margin-bottom:13px}
.ct{font-size:11.5px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
.g2{display:grid;grid-template-columns:1fr 1fr;gap:13px}
.g3{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}
.g4{display:grid;grid-template-columns:repeat(4,1fr);gap:13px}
.metric{background:var(--bg);border-radius:var(--r);padding:12px 14px;border:1px solid var(--border)}
.metric{background:var(--bg);border-radius:var(--r);padding:14px 16px;border:1px solid var(--border)}
.ml{font-size:12px;color:var(--muted)}
.mv{font-size:24px;font-weight:500;margin-top:2px;line-height:1}
.badge{display:inline-flex;align-items:center;padding:2px 9px;border-radius:9px;font-size:12px;font-weight:500;white-space:nowrap}
@@ -167,7 +168,7 @@ textarea{resize:vertical;min-height:80px}
.spin{width:16px;height:16px;border:2px solid var(--border2);border-top-color:var(--green);border-radius:50%;animation:sp .7s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
.mbg{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:100;display:flex;align-items:center;justify-content:center}
.modal{background:var(--surface);border-radius:var(--rl);padding:22px;width:640px;max-width:95vw;max-height:88vh;overflow-y:auto;border:1px solid var(--border)}
.modal{background:var(--surface);border-radius:var(--rl);padding:22px;width:640px;max-width:95vw;max-height:88vh;overflow-y:auto;border:1px solid var(--border);box-shadow:0 8px 32px rgba(43,34,20,.16)}
.modal-t{font-size:15px;font-weight:500;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between}
.toast{position:fixed;bottom:22px;right:22px;background:var(--text);color:#fff;padding:9px 16px;border-radius:var(--r);font-size:13px;z-index:200;animation:tu .18s ease;display:flex;align-items:center;gap:7px}
@keyframes tu{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}}
@@ -236,6 +237,39 @@ textarea{resize:vertical;min-height:80px}
.drop-zone{border:2px dashed var(--border);border-radius:var(--r);padding:24px;text-align:center;cursor:pointer;transition:all .15s;color:var(--muted);font-size:13px;margin-bottom:10px}
.drop-zone.over{border-color:var(--green);background:var(--gbg);color:var(--gtx)}
.drop-zone i{font-size:28px;display:block;margin-bottom:6px}
/* ── Mobile-Menü (Hamburger + Off-Canvas-Sidebar) ─────────────────────────── */
.sb-toggle{display:none;flex-shrink:0}
.sb-backdrop{display:none;position:fixed;inset:0;background:rgba(24,20,14,.42);z-index:190}
.sb-backdrop.show{display:block}
/* ── Responsive: Tablet ────────────────────────────────────────────────── */
@media (max-width:900px){
.sb{position:fixed;top:0;left:0;height:100vh;z-index:200;transform:translateX(-100%);box-shadow:4px 0 24px rgba(24,20,14,.18)}
.sb.open{transform:translateX(0)}
.sb-toggle{display:inline-flex}
.topbar{padding:0 14px 0 10px;gap:8px}
.content{padding:14px}
.fg.c3,.fg.c2{grid-template-columns:1fr}
.g4{grid-template-columns:1fr 1fr}
.modal{padding:18px}
.wiz-steps{overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:2px}
.wiz-title{font-size:15px}
}
/* ── Responsive: Handy ─────────────────────────────────────────────────── */
@media (max-width:560px){
.g4,.g3,.g2{grid-template-columns:1fr}
.card{padding:15px 16px}
.ptitle{font-size:14px}
.btn{padding:9px 13px;min-height:38px}
.btn-sm{padding:7px 11px;min-height:34px}
.btn-ico{padding:8px}
#tactions{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
.modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;border-radius:0}
.mbg{padding:0}
.kk-wrap input,.fgrp input,.fgrp select,.fgrp textarea{font-size:16px}
}
</style>
</head>
<body>
@@ -278,6 +312,8 @@ textarea{resize:vertical;min-height:80px}
<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">Konto</div>
<div class="ni" data-p="meinabo"><i class="ti ti-credit-card"></i>Mein Abo</div>
</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>
@@ -294,11 +330,13 @@ textarea{resize:vertical;min-height:80px}
</div>
<div class="main">
<div class="topbar">
<button class="sb-toggle btn btn-ico" id="sb-toggle" onclick="toggleSidebar()" aria-label="Menü"><i class="ti ti-menu-2"></i></button>
<div class="ptitle" id="ptitle">Dashboard</div>
<div id="tactions"></div>
</div>
<div class="content" id="content"><div class="spin-wrap"><div class="spin"></div>Lade…</div></div>
</div>
<div class="sb-backdrop" id="sb-backdrop" onclick="closeSidebar()"></div>
</div>
<div class="mbg hidden" id="modal">
<div class="modal">
@@ -317,18 +355,31 @@ let kkData=null;
const kkCB={};
let _einstellungen={};
let curUser=null;
let curAbo=null;
let aboBlockiert=false;
document.querySelectorAll('.ni[data-p]').forEach(el=>{
el.addEventListener('click',()=>go(el.dataset.p));
el.addEventListener('click',()=>{go(el.dataset.p);closeSidebar();});
});
function toggleSidebar(){
document.querySelector('.sb')?.classList.toggle('open');
document.getElementById('sb-backdrop')?.classList.toggle('show');
}
function closeSidebar(){
document.querySelector('.sb')?.classList.remove('open');
document.getElementById('sb-backdrop')?.classList.remove('show');
}
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';
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})[name]?.(extra);
kalender:pgKalender,backend:pgBackend,admindash:pgAdminDash,meinabo:pgMeinAbo})[name]?.(extra);
}
async function api(path,opts={}){
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});
@@ -877,6 +928,10 @@ async function initApp(){
return;
}
curUser=me;
try{
curAbo=await api('/api/abo/status');
aboBlockiert=!!(curAbo&&curAbo.zugang_aktiv===false);
}catch(e){curAbo=null;aboBlockiert=false;}
document.getElementById('lo-overlay').style.display='none';
const uw=document.getElementById('sb-user');
if(uw){uw.style.display='flex';}
@@ -922,6 +977,37 @@ async function doLogout(){
const uw=document.getElementById('sb-user');if(uw)uw.style.display='none';
}
// ── Mein Abo (Zahlungs-Gerüst – Anbieter voraussichtlich PayPal) ──────────
async function pgMeinAbo(){
R('<div class="spin-wrap"><div class="spin"></div>Lade…</div>','Mein Abo');
const a=await api('/api/abo/status');
curAbo=a;
if(a.ist_admin){
R('<div class="card" style="max-width:480px"><div class="slabel mb3">Mein Abo</div>'+
'<p>Als Administrator haben Sie uneingeschränkten Zugang – kein Abo erforderlich.</p></div>','Mein Abo');
return;
}
const gesperrt=aboBlockiert;
const statusBadge=a.zugang_aktiv?'<span class="badge b-g">Zugang aktiv</span>':'<span class="badge b-r">Kein Zugang</span>';
const bisText=a.abo_bis
?('gültig bis '+fdate(a.abo_bis)+(a.tage_verbleibend!=null?(' · noch '+a.tage_verbleibend+' Tag(e)'):''))
:'noch kein Zugang freigeschaltet';
R('<div class="card" style="max-width:480px">'+
(gesperrt?'<div class="alert a-warn mb4"><i class="ti ti-lock"></i><p>Ihr Zugang ist aktuell gesperrt. Bitte aktivieren Sie Ihr Abo, um Pflegepilot weiter zu nutzen.</p></div>':'')+
'<div class="slabel mb3">Mein Abo</div>'+
'<div class="fl ic g8 mb3">'+statusBadge+'<span class="sm muted">'+bisText+'</span></div>'+
'<div class="wiz-hint">Pflegepilot kostet <strong>'+a.preis+' '+a.waehrung+' / Monat</strong>.'+
(a.abo_pflicht_aktiv?'':' Die Zahlungspflicht ist aktuell noch nicht aktiv – Sie können die App unabhängig von Ihrem Abo-Status nutzen.')+
'</div>'+
'<button class="btn btn-p" style="width:100%" onclick="aboJetztBezahlen()"><i class="ti ti-brand-paypal"></i>Mit PayPal bezahlen</button>'+
'<div class="sm muted mt2">Online-Zahlung folgt in Kürze. Bei Fragen wenden Sie sich bitte an den Betreiber.</div>'+
'</div>','Mein Abo');
}
async function aboJetztBezahlen(){
const r=await api('/api/abo/aktivieren',{method:'POST'});
toast(r.error||'Zahlung folgt in Kürze',false);
}
// ── Backend / Admin-Einstellungen ─────────────────────────
async function pgBackend(){
if(!curUser||curUser.rolle!=='admin'){
@@ -1001,6 +1087,22 @@ async function pgBackend(){
(n.id!==curUser?.id?'<button class="btn btn-sm btn-ico" style="color:var(--rdtx)" onclick="delNutzer('+n.id+')"><i class="ti ti-trash"></i></button>':'')+
'</div>').join(''))+
'</div></div>'+
'</div>'+
// ── Zeile 3 ──
'<div class="g2 mt3">'+
// Abo & Zahlung
'<div class="card">'+
'<div class="ch"><span class="ct">Abo &amp; Zahlung</span>'+
(s.abo_pflicht==='1'?'<span class="badge b-r">Zahlungspflicht aktiv</span>':'<span class="badge b-gr">Zahlungspflicht inaktiv (Testphase)</span>')+'</div>'+
'<div class="alert a-info mb4"><i class="ti ti-info-circle"></i><p>Solange die Zahlungspflicht inaktiv ist, können alle Konten die App unabhängig von ihrem Abo-Status nutzen. Erst wenn PayPal wirklich angebunden ist, sollte hier auf "aktiv" umgestellt werden – sonst werden Nutzer ohne Bezahlmöglichkeit ausgesperrt.</p></div>'+
'<div class="fg c2 mb4">'+
'<div class="fgrp"><label>Preis pro Monat</label><div class="fl g8"><input id="e-abo_preis" value="'+(s.abo_preis||'4.99')+'" style="width:90px"><span class="sm muted" style="align-self:center">€ / Monat</span></div></div>'+
'<div class="fgrp"><label>Zahlungspflicht</label><select id="e-abo_pflicht"><option value="0"'+(s.abo_pflicht!=='1'?' selected':'')+'>Inaktiv (jeder hat Zugang)</option><option value="1"'+(s.abo_pflicht==='1'?' selected':'')+'>Aktiv (Abo erforderlich)</option></select></div>'+
'</div>'+
'<div class="fl g8" style="justify-content:flex-end">'+
'<button class="btn btn-p" onclick="saveEinst([\'abo_preis\',\'abo_pflicht\'])"><i class="ti ti-check"></i>Speichern</button></div>'+
'</div>'+
'</div>',
'Backend-Einstellungen',
'<span class="badge b-b" style="padding:6px 12px"><i class="ti ti-settings-2" style="margin-right:4px"></i>Admin</span>');
@@ -1077,7 +1179,7 @@ async function uploadLogo(input){
// ── 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'};
konto_geloescht:'Konto gelöscht',dsgvo_export:'DSGVO-Export',abo_freigeschaltet:'Abo freigeschaltet'};
async function pgAdminDash(){
R('<div class="spin-wrap"><div class="spin"></div>Lade…</div>','Admin-Dashboard');
const [nutzer,log,patienten]=await Promise.all([
@@ -1089,18 +1191,23 @@ function renderAdminDash(nutzer,log,patienten){
const rows=(nutzer||[]).map(n=>{
const gesperrt=!n.aktiv;
const isSelf=n.id===curUser?.id;
const aboText=n.rolle==='admin'?'<span class="sm muted">–</span>':
(n.abo_bis?(n.zugang_aktiv?'<span class="badge b-g">bis '+fdate(n.abo_bis)+'</span>':'<span class="badge b-r">abgelaufen '+fdate(n.abo_bis)+'</span>')
:'<span class="badge b-gr">kein Zugang</span>');
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>'+aboText+'</td>'+
'<td>'+(isSelf?'<span class="sm muted">Eigenes Konto</span>':
'<div class="fl g8">'+
'<div class="fl g8" style="flex-wrap:wrap">'+
(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>':'')+
'<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>';
@@ -1117,7 +1224,7 @@ function renderAdminDash(nutzer,log,patienten){
).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>'+
'<div class="tw"><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>'+
'<tbody>'+rows+'</tbody></table></div>'+
'</div>'+
'<div class="card mb4">'+
@@ -1158,6 +1265,28 @@ async function adminNutzerAktion(uid,aktion){
toast('Erledigt'+(r.archivierte_patienten?' – '+r.archivierte_patienten+' Patient(en) archiviert':''));
pgAdminDash();
}
function adminAboModal(uid,name){
OM('Abo freischalten – '+escH(name),
'<div class="wiz-hint">Schaltet dem Konto unabhängig von einer Zahlung Zugang für die gewählte Anzahl Monate frei (z. B. Testphase, Kulanz). Verlängert ein bestehendes Abo, statt es zu überschreiben.</div>'+
'<div class="fg c2 mb4">'+
'<div class="fgrp"><label>Monate</label><select id="admin-abo-monate">'+[1,2,3,6,12].map(m=>'<option value="'+m+'">'+m+' Monat'+(m>1?'e':'')+'</option>').join('')+'</select></div>'+
'</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="adminAboFreischalten('+uid+')"><i class="ti ti-check"></i>Freischalten</button></div>'
);
}
async function adminAboFreischalten(uid){
const grund=(document.getElementById('admin-grund')?.value||'').trim();
const monate=document.getElementById('admin-abo-monate')?.value||1;
if(!grund){toast('Begründung erforderlich',false);return;}
const r=await api('/api/admin/nutzer/'+uid+'/abo-freischalten',{method:'POST',body:JSON.stringify({begruendung:grund,monate:parseInt(monate)})});
if(r&&r.error){toast(r.error,false);return;}
CM();
toast('Abo freigeschaltet bis '+fdate(r.abo_bis));
pgAdminDash();
}
async function dsgvoExport(){
const pid=document.getElementById('dsgvo-pid')?.value;
const grund=(document.getElementById('dsgvo-grund')?.value||'').trim();