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:
1 parent
eb57a6eccf
commit
0884f02e15
3 files changed
+291
-23
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 & 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 & 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 & 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();
|
||||
|
||||
Reference in new issue
Block a user