pflegepilot_v3: Feature-Ausbau (T1-T18 Tester-Backlog), System-Backup/Restore, Landingpage-SEO

Grosser Funktionsschub ueber mehrere Sessions: Vollmacht- und
Patientenverfuegung-Generator, Umkreissuche fuer Pflegedienste/Sanitaets-
haeuser/Wohnformen/Therapie inkl. Favoriten und Aenderungsbenachrichtigung,
Dokumenten-Scan sowie eGK-Kartenerfassung per Kamera, Budget-Tracker,
Krisen-Checklisten, PLZ-Autofill, Kalender-Export, Passwort-Reset per SMTP,
Checkliste vor Antragstellung, Leistungsauswahl/-rolle im Interview mit
Steuervorteil-Hinweis. Admin-seitig: System-Backup inkl. Wiederherstellung
aus einem hochgeladenen Archiv, ueberarbeitetes Dashboard mit Patienten-
Kacheln. Landingpage inhaltlich und fuer SEO ueberarbeitet (Server-
Metadaten, JSON-LD, robots.txt/sitemap.xml), Hero-Text und SEO-Meta ueber
die Backend-Einstellungen editierbar gemacht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-07-29 20:36:33 +02:00
1 parent 515c3eacf3
commit 69613a8a75
113 files changed
+9421 -254

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ pflegepilot_v3/backend/.venv*/
pflegepilot_v3/frontend/node_modules/
pflegepilot_v3/frontend/.next/
pflegepilot_v3/.env
pflegepilot_v3/secrets/
+214
View File
@@ -2,6 +2,16 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Naming: pflegepilot_v3's product is now branded "MeinPflegeFuchs" (renamed 2026-07-24)
The `pflegepilot_v3` product was renamed from "Pflegepilot" to **"MeinPflegeFuchs"** (domain: meinpflegefuchs.de, ordered via Strato) — there turned out to be several unrelated existing apps/services already using "Pflegepilot" or very close variants, discovered when checking candidate replacement names for trademark/confusion risk (a lesson worth repeating: the German Pflege-app naming space is extremely crowded — a `"Pflege" + descriptive word` pattern is very likely to already be taken by a real competing service; "MeinPflegeFuchs" was chosen specifically because it's a mascot-style name rather than a literal descriptor).
- **Every user-visible string, generated-document footer, email subject, page title/metadata, and the two legal texts under `rechtstexte/` were updated** to "MeinPflegeFuchs" — see the `changelog_seed.py` entry (version 1.1.0) for the user-facing announcement of the rename.
- **The `pflegepilot_v3` directory name, package/module names, DB names, and NAS deployment path (`/Dockers/pflege`) were deliberately left unchanged** — renaming those would touch git history, every absolute path referenced throughout this file and past deployment scripts, and the NAS's manually-maintained (non-git) copy, for zero user-facing benefit. Don't be confused that the folder still says "pflegepilot" while the product itself is called MeinPflegeFuchs — that's intentional, not a leftover.
- Only scope was `pflegepilot_v3` — `pflegepilot_alpha_v2` (the old, separately-running Flask app) still legitimately calls itself Pflegepilot and was not touched.
- Historical references to "Pflegepilot" — e.g. the changelog's 1.0.0 entry, or anything below in this file describing what was true *at the time it was written* — are accurate records of the past and were deliberately left as-is rather than rewritten; only forward-looking/current-state documentation and all actual product code got renamed. When adding new documentation to this file, use "MeinPflegeFuchs", not "Pflegepilot".
- `einstellungen.firmenname` (sidebar branding / PDF-footer company name) is a separate, operator-configured field for the *legal entity* name (currently "Daniel Barac Onlinedienste" in production) — unrelated to the product brand name and intentionally not touched by this rename.
## Repository layout
This is not a single project but a working directory with one active app plus reference/planning material:
@@ -129,6 +139,48 @@ Added 2026-07-24 per customer feedback: when a patient moves, both the Krankenka
Added 2026-07-24: until then there was no way to fix a typo made at patient creation — only Pflegegrad (`PATCH .../pflegegrad`) and Notizen (`POST .../notizen`, audit-trailed via `notizen_verlauf`) had dedicated update routes/UI; every other field (name, address, contact, insurance) was write-once via the "Neuer Patient" wizard. `PUT /api/patienten/{pid}` (`PatientUpdate` schema, generic `setattr` loop in `routers/patienten.py`) already supported updating all of these — it just had no frontend form. `app/(app)/patienten/[id]/bearbeiten/page.tsx` is that form: a single-page (not multi-step, unlike the creation wizard) edit form prefilled from the current patient, "Bearbeiten" link added next to "Löschen" on the patient detail page. Deliberately excludes `notizen` (keeps going through its own audited flow) and `patient_status`/`pflegegrad`/`foto_pfad` (each has its own dedicated update path already).
## Passwort-vergessen per SMTP (pflegepilot_v3 only, 2026-07-24) — deliberate exception to "no SMTP"
The operator noticed self-registered accounts had no email address at all, so a "Passwort vergessen" flow had no way to verify identity. Fixed by adding `nutzer.email` (unique, mandatory on new self-registrations, optional/backfillable on admin-created accounts) plus a standard token-based reset flow.
**This was, until 2026-07-28, the only place in `pflegepilot_v3` where the server sends email of its own accord.** (The "Email: mailto hand-off, not SMTP" section further below describes `pflegepilot_alpha_v2`'s design, not v3's — v3's own Musterbrief/Widerspruch/Ummeldung/Vollmacht generators don't send email at all, mailto or otherwise, they only offer a PDF download.) Two more proactive-email use cases were added on 2026-07-28 with operator sign-off; see "Proaktiver Server-Mailversand erweitert" below for the current, complete list and the reasoning for each. Don't add a *fourth* proactive-email use case without the same kind of explicit check-in — this isn't meant to become the default pattern for every notification idea.
- `app/email_util.py: send_email()` is the one place SMTP is touched. Configured via `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASSWORD`/`SMTP_FROM`/`SMTP_USE_SSL`/`SMTP_USE_TLS` env vars (`app/config.py`), all optional. **As long as `SMTP_HOST` is unset, it only logs a warning and returns — never raises.** This was deliberate, not a stopgap to remove later: `POST /api/auth/passwort-vergessen` must always return the same generic response regardless of whether the email exists or whether sending succeeds (anti-enumeration), so `send_email()` swallowing its own failures is exactly the right shape for that caller, not a bug.
- `docker-compose.yml`'s `api.environment` block only passes through explicitly listed keys — adding a new `Settings` field in `config.py` is not enough by itself, the corresponding `${VAR}` line must also be added to `environment:` (this bit locally the first time: the `.env` value was set correctly but never reached the container until `docker-compose.yml` itself was updated). `SMTP_USE_SSL`/`smtp_use_ssl` was added alongside `smtp_use_tls` (STARTTLS) because Strato (the operator's SMTP provider) offers both port 465 (implicit SSL, `smtplib.SMTP_SSL`) and 587 (STARTTLS on a plaintext-then-upgraded connection) — these are different connection modes, not interchangeable, and the operator's provider notes specified SSL/465. **Live and verified 2026-07-24** against the operator's real Strato mailbox (`support@db-onlinedienste.de`, port 465/SSL) — both a direct `send_email()` call and the actual `/api/auth/passwort-vergessen` endpoint were tested end-to-end with no errors logged.
- **The real SMTP password lives in `pflegepilot_v3/secrets/SMTP-Secrets.txt`** (gitignored — this folder was untracked-but-NOT-ignored when the operator first created it, meaning a stray `git add -A` would have committed a plaintext password; added to `.gitignore` in the same pass this feature shipped). Never read this file's contents into a chat response, a commit, or any other persisted location — when working with it, treat the value as write-only: read it, pipe it directly into `.env` via file transfer (not inline shell arguments — special characters in the actual password broke naive shell-quoting attempts elsewhere in this project's history), never echo or log it.
- Reset tokens: `secrets.token_urlsafe(32)`, only the SHA-256 hash is stored (`nutzer.reset_token_hash`/`reset_token_laeuft_ab`), 1-hour validity, single-use (cleared on successful reset). Same rate-limiting pattern as `login`/`registrieren` (`is_rate_limited`, keyed by IP).
- The reset-link landing page (`/passwort-zuruecksetzen?token=...`) had to go in `(marketing)/`, not `(app)/` — the `(app)` layout shows a blocking `AuthOverlay` for anyone not logged in, which would swallow the page before the reset form ever rendered. `(marketing)/` has no such gate, which is exactly why Impressum/Datenschutz/AGB already live there.
- **Existing accounts created before this change have no email** and can't use password-reset until one is set — either the account holder re-registers, or an admin sets `email` via `PUT /api/nutzer/{uid}` (schema-ready; no dedicated admin edit-account modal exists yet in pflegepilot_v3's admin dashboard to do this through the UI — only the quick-create form and sperren/entsperren/löschen/Freischalten/Rechnung actions exist there today). Building that modal is a natural next step but wasn't part of this task.
## Berechtigungsbestätigung je Patient (pflegepilot_v3 only, 2026-07-24)
Operationalizes what the ToS (Ziffer 5) and Datenschutzerklärung (Ziffer 7) already say in prose — that the Nutzer, not Pflegepilot, is responsible for having a legal basis (Einwilligung, Pflege-/Fürsorgebeziehung, or rechtliche Vertretung) to enter another person's data — as an actual captured declaration, same pattern as the Widerrufsrecht consent at registration (§ 356 BGB) added earlier the same day.
- **Per patient, not once per account** (deliberate choice, confirmed with the operator): each care recipient is a distinct person who may have a distinct authorization basis (e.g. Vorsorgevollmacht for one parent but not the other), so the checkbox appears on Step 6 ("Übersicht") of the "Neuer Patient" wizard every time, not just for the first patient created.
- Enforced server-side (`POST /api/patienten` rejects with 400 if `berechtigung_bestaetigt` isn't `true`), not just a disabled button — same reasoning as the registration consent: a client-only gate is trivially bypassed by calling the API directly.
- `patient.berechtigung_bestaetigt_am` (migration `f552607a045e`) stores the timestamp as the evidentiary record, mirroring `nutzer.widerrufsverzicht_bestaetigt_am`. Not exposed via `PatientOut` (kept DB-only, same restraint as the Nutzer field) — query directly if ever needed.
- `PatientCreate.berechtigung_bestaetigt` is excluded from the `model_dump()` spread into the `Patient(...)` constructor in `routers/patienten.py` (it isn't a column — only `_am` is) — don't add fields to `PatientCreate` that aren't 1:1 `Patient` columns without checking this exclusion still holds.
## Incident 2026-07-24: German-comma price value crashed the entire admin dashboard
`app/einstellungen.py: abo_preise()` used to do a bare `decimal.Decimal(get_setting(...))`. The admin saved the "Aufpreis je zusätzlichem Patienten" field in Backend-Einstellungen with a German-formatted value (`"4,99"` — comma, not dot) — completely natural input for a German user, but `decimal.Decimal("4,99")` raises `InvalidOperation`. Since `abo_preise()` is called from `GET /api/nutzer` (admin's Nutzerliste, to compute `abo_preis_aktuell` per account), this took down the **entire** admin account list with a 500 — reported by the operator as "the accounts are gone," when in fact no data was lost at all, only the listing endpoint crashed.
Fixed in two ways, not just one: `_preis_decimal()` normalizes `,` → `.` before parsing, *and* falls back to the compiled-in default on any other `InvalidOperation` rather than propagating the exception — a plain textarea/text-input settings field will eventually get a bad value again (typo, copy-paste artifact, whatever), and a single bad price string must never be able to crash every endpoint that happens to call `abo_preise()` (currently: admin Nutzerliste, `/api/abo/status`, `/api/einstellungen/public`, invoice generation). The admin Einstellungen form (`app/(app)/admin/einstellungen/page.tsx`) also got a small inline hint under both price fields ("bitte mit Punkt, nicht Komma") — cheap prevention, but the defensive parsing is the real fix; don't remove it even if the hint seems to make it redundant.
**General lesson for this settings store**: any numeric `einstellungen` value that gets `decimal.Decimal()`/`int()`/etc.-parsed at read time is one bad save away from taking down every caller, since `get_setting()` returns whatever string is stored with no validation at write time. If you add another numeric settings key, parse it as defensively as `_preis_decimal()` does — don't assume the stored string is well-formed just because the admin UI is "just" a plain text input.
## Legal-page links inside the logged-in app shell (pflegepilot_v3 only, 2026-07-24)
Gap found by the operator: `pflegepilot_alpha_v2` already has a `.sb-legal` sidebar link row for Impressum/Datenschutz/AGB (see that section above), but `pflegepilot_v3`'s `(app)/layout.tsx` never got the equivalent — once logged in, there was no way to reach any of the three legal pages at all (they only existed via the `(marketing)/` footer, which a logged-in user viewing `/dashboard` etc. never sees). Fixed with a small link row at the bottom of the sidebar `<aside>` (below the user/logout block), `target="_blank"` so it doesn't navigate the user away from whatever they were doing in the app. Lives in the same `<aside>` used for both the desktop-static and mobile-drawer sidebar, so no separate mobile handling was needed.
## Widerrufsrecht-Einwilligung bei Registrierung (pflegepilot_v3 only, 2026-07-24)
Added while drafting the real Datenschutzerklärung/Nutzungsbedingungen (`pflegepilot_v3/rechtstexte/`, operator-authored legal text, not application code, but referenced here since it drove this feature): since the service begins performance immediately on registration (account access, letter generation) rather than after the statutory 14-day Widerrufsfrist, § 356 Abs. 5 BGB requires the consumer's *express* consent to that early start plus explicit confirmation that they know they lose the right of withdrawal once the service is fully performed — a generic "I accept the ToS" checkbox does not satisfy this.
- Two separate mandatory checkboxes on the registration form (`components/AuthOverlay.tsx: RegisterForm`), each with its own exact legal wording (matching `Nutzungsbedingungen.txt` verbatim — if the ToS wording changes, update both places together): consent to early performance, and acknowledgment of losing the withdrawal right. Submit is disabled client-side until both are checked, and links to `/agb` open in a new tab (`target="_blank"`) so the registration form isn't lost.
- **Enforced server-side, not just client-side**: `POST /api/auth/registrieren` (`routers/auth.py`) rejects with 400 unless both `zustimmung_sofortbeginn` and `zustimmung_widerrufsverzicht` are `true` — a client-side-only checkbox gate would be trivial to bypass by calling the API directly, which would leave no valid consent record.
- `nutzer.widerrufsverzicht_bestaetigt_am` (migration `212cec88ae07`) stores *when* this consent was given, as the evidentiary record the ToS text promises ("Der Zeitpunkt dieser Bestätigung wird zu Nachweiszwecken gespeichert") — if a customer ever disputes having lost their withdrawal right, this timestamp is the proof. Not currently surfaced in the admin UI (query the DB directly if ever needed); add a display there if that becomes a recurring need.
## Krankenkassen-Stammdaten: completed against the official GKV list (2026-07-24)
A customer report of one missing insurer (`BKK Gildemeister-Seidensticker`) turned out to be a symptom of a bigger gap: `static_data/krankenkassen.py` was a curated subset (~87 entries) ported 1:1 from the old app, never checked against the actual complete set of German statutory insurers. Fixed properly rather than one entry at a time — cross-referenced against the official GKV-Spitzenverband list (93 gesetzliche Krankenkassen, Stand 2026) via a `transparent-beraten.de` summary PDF, then researched an individual verified address (from each insurer's own Impressum, preferring their stated Postfach/postal address over the street address where one exists) for every missing GKV entry. Now **94 German GKV entries** (essentially complete — the file also carries the full existing PKV/AT-SV/CH-KVG sections unchanged).
@@ -138,6 +190,166 @@ A customer report of one missing insurer (`BKK Gildemeister-Seidensticker`) turn
- Many entries prefer a **Postfach address over the street address** where the insurer's own Impressum lists one as the designated correspondence address (mirroring the DRV Bund precedent) — don't "simplify" these back to a street address without checking the source first.
- This list still won't stay perfectly current forever — Krankenkassen merge and rename periodically (as the IKK/actimonda cases above show happened within the last few years). No automated staleness check exists; re-verifying against the GKV-Spitzenverband list is a manual, occasional task.
## Budget-Tracker & Krisen-Checklisten (pflegepilot_v3 only, 2026-07-28)
Zwei Tester-Feedback-Punkte, beide unabhängig voneinander:
- **Budget-Tracker** (`leistungen.budget_jahr` + neue Tabelle `leistung_ausgaben`, Endpoints in `routers/leistungen.py`, UI-Komponente `components/BudgetTracker.tsx` je Leistung in der Patientenakte): Jahresbudget + Ausgabenerfassung pro Leistung, zeigt den Restbetrag mit einem festen Verfallshinweis "30.06. des Folgejahres" (passend zum bestehenden Entlastungsbetrag-Übertrag im Katalog). Bewusst **kein** Mehrjahres-Rollover-Ledger (z. B. getrennte Buchführung "verfallener Rest aus Vorjahr" vs. "aktuelles Jahresbudget") — das wäre für eine "kurze visuelle Übersicht" (Wortlaut des Tester-Feedbacks) überkonstruiert; bei Bedarf später ausbaufähig.
- **Krisen-Checklisten** (`static_data/krisenguides.py`, Endpoint `GET /api/krisenguides`, Seite `/guides`): vier Schritt-für-Schritt-Anleitungen (plötzliche Hospitalisierung, Sturz/Notfall, Tod des Angehörigen, Ausfall der Pflegeperson) — Liste mit dem Betreiber abgestimmt, da der Tester nur ein Beispiel nannte und um Ausarbeitung bat. Reiner Content, kein Rechtstext — ersetzt nie den Notruf 112.
## Proaktiver Server-Mailversand erweitert (pflegepilot_v3 only, 2026-07-28)
Tester-Feedback bat um zwei neue proaktive Benachrichtigungen (Leistungskatalog-Änderung, tägliche Tagebuch-Erinnerung), beide strukturell ohne jede Nutzeraktion, an die sich ein Versand anhängen ließe. Wichtig zur Einordnung: `pflegepilot_v3`s Musterbrief/Widerspruch/Ummeldung/Vollmacht-Generatoren haben **kein** Mailversand-Feature jeglicher Art (weder mailto noch SMTP) — sie erzeugen serverseitig ein PDF und bieten nur "PDF öffnen" zum Download an (die `mailto:`-Weiterleitung aus dem Abschnitt "Email: mailto hand-off, not SMTP" unten ist eine Beschreibung von `pflegepilot_alpha_v2`, nicht von v3 - dort erkennbar an `templates/index.html`/`sendMailModal()`, alten vanilla-JS-Bezeichnern). Die beiden neuen Punkte hier sind also nicht "eine dritte Ausnahme neben mailto", sondern schlicht die ersten beiden Fälle in v3, in denen serverseitig überhaupt eine Mail verschickt wird, außer dem Passwort-Reset. Vom Betreiber ausdrücklich freigegeben, nicht eigenmächtig entschieden. Die vollständige, aktuelle Liste proaktiver Mailversand-Faelle in v3:
1. **Passwort-Reset** (siehe "Passwort-vergessen per SMTP" oben) — die ursprüngliche, einzige Ausnahme.
2. **Leistungskatalog-Änderungsbenachrichtigung** (`app/katalog_watch.py`) — vergleicht bei jedem App-Start den aktuellen `KATALOG`-Stand (`static_data/katalog.py`) gegen einen in `katalog_snapshots` gespeicherten Schnappschuss (Betrag/Voraussetzung/Rechtsgrundlage je `katalog_id`). Weicht ein Eintrag ab, werden alle Nutzer benachrichtigt, die diese Leistung einem Patienten zugewiesen haben (`leistungen.katalog_id`) — sowohl der Nutzer selbst als auch der Patient, falls dieser eine eigene E-Mail hat. Erster Lauf nach Deploy legt nur den Baseline-Schnappschuss an, ohne zu benachrichtigen (sonst würde der komplette Katalog beim allerersten Start als "geändert" gemeldet).
3. **Tägliche Tagebuch-Check-in-Erinnerung** (`app/reminder_scheduler.py`) — je Nutzer ein/ausschaltbar mit frei wählbarer Uhrzeit (`nutzer.reminder_aktiv`/`reminder_uhrzeit`, Einstellungsseite unter `/einstellungen`). Da diese App bewusst **keine** Scheduler-/Cron-Infrastruktur hat (der Demo-Reset läuft absichtlich über einen Lazy-Check bei jedem Request statt über einen eigenständigen Hintergrundprozess, siehe "Demo account" unten), war das die erste Stelle, an der eine echte, uhrzeitgenaue Wiederkehr gebraucht wurde. Gelöst mit einem simplen Python-Bordmittel-Hintergrundthread (kein neuer Dependency wie APScheduler/Celery) statt echtem Cron.
**Beide neuen Mechanismen teilen dasselbe Multi-Worker-Problem und denselben Lösungsansatz**: Gunicorn startet mehrere Worker-Prozesse, die denselben Startup-/Thread-Code alle gleichzeitig ausführen würden — ohne Schutz gäbe es doppelte Benachrichtigungsmails (dieselbe Klasse von Bug wie der dokumentierte Changelog-Duplikat-Vorfall, nur diesmal mit einem Seiteneffekt statt einem reinen Insert, wo `ON CONFLICT DO NOTHING` nicht ausreicht). Beide nutzen deshalb einen **Postgres-Advisory-Lock** (`pg_try_advisory_lock`, je ein fest gewählter, eindeutiger Integer-Schlüssel): bei `katalog_watch.py` nur für die Dauer eines einzelnen Abgleichs beim Start, bei `reminder_scheduler.py` über die **gesamte Lebensdauer** des Hintergrund-Threads (Lock hängt an einer dauerhaft offenen Connection). Nur der Worker, der den Lock zuerst bekommt, macht die Arbeit; alle anderen überspringen sie für diesen Prozess-Start vollständig — ausreichend, da genau ein Worker im gesamten Container die Aufgabe übernehmen muss, nicht jeder einzelne.
`app/email_util.py: send_email()` bleibt die einzige Stelle, an der SMTP angefasst wird, für alle drei Fälle gemeinsam genutzt.
## Umkreissuche für lokale Pflege-Dienstleister (pflegepilot_v3 only, 2026-07-28, T10–T13)
Tester-Feedback wollte eine Umkreissuche nach PLZ für Pflegedienste, Sanitätshäuser, diverse Wohnformen (Pflegeheim, betreutes Wohnen, Tagespflege, Demenz-Wohngruppe, soziales Café) und Therapie/Reha-Einrichtungen (Logopädie, Physiotherapie, Reha-Zentrum). Anders als bei Krankenkassen/Rentenversicherung (ein paar Dutzend bis ~100 Einträge, handkuratierbar) gibt es hier mehrere Tausend Anbieter bundesweit — eine statische Liste war nie eine Option.
- **Datenquellen-Entscheidung, bewusst vom Betreiber getroffen (nicht von Claude)**: OpenStreetMap statt Google Places API — kostenlos, kein API-Key/Account/Zahlungsmethode nötig, dafür lückenhaftere/uneinheitlicher gepflegte Daten als ein kommerzieller Dienst. Zwei separate OSM-Dienste sind beteiligt: **Nominatim** (PLZ → Koordinaten) und **Overpass API** (`overpass-api.de`, getaggte Einrichtungen im Umkreis). Beide sind offene Community-Infrastruktur ohne Key — dafür ist der öffentliche Overpass-Mirror gelegentlich träge/kurzzeitig überlastet, siehe Betriebshinweis unten.
- **`app/umkreissuche.py`** ist die zentrale Logik: `KATEGORIEN`-Registry (Kategorie-Key → Label + Liste von Overpass-Tag-Filtern), `_geocode_plz()`, `_overpass_suche()` (baut die Overpass-QL-Query, ein Retry-Versuch mit 2s Pause gegen transiente Timeouts des öffentlichen Mirrors), `suche()` (Ergebnis-Parsing, Haversine-Distanzberechnung, Sortierung nächste zuerst, gekappt auf 30 Treffer). **Bewusst als Registry statt if/elif-Kette** — T10 legte nur `pflegedienst` an, T11–T13 haben ausschließlich neue `KATEGORIEN`-Einträge ergänzt, ohne Suchlogik/Router/Frontend anzufassen (Frontend-Kategorieauswahl ist datengetrieben über `GET /api/umkreissuche/kategorien`, brauchte für T11–T13 keine einzige Zeile Änderung).
- **OSM-Tags je Kategorie recherchiert gegen OSM-Wiki (`DE:Tag:...`-Seiten) und taginfo.openstreetmap.org, nicht geraten**: `amenity=social_facility`+`social_facility=ambulatory_care` (Pflegedienst), `shop=medical_supply` (Sanitätshaus), `social_facility=nursing_home`/`assisted_living`/`day_care`/`group_home`+`for=dementia` (Wohnformen — jeweils zusätzlich das ältere, laut Wiki noch nicht vollständig migrierte `amenity=nursing_home`/`retirement_home`-Tag mitabgefragt, damit nicht-migrierte Bestandsdaten nicht verloren gehen), `healthcare=speech_therapist`/`physiotherapist`/`rehabilitation` (Therapie/Reha).
- **Zwei bekannte, bewusste Lücken statt vorgetäuschter Vollständigkeit**: "Soziales Café" hat kein verlässliches OSM-Tag — behelfsweise über `amenity=community_centre` abgebildet, Label sagt explizit "(geringe Datenlage)". "Reha-Sport" wurde als eigene Kategorie **ausgelassen** — es gibt dafür überhaupt kein brauchbares OSM-Tag (Kurse finden meist in normalen Sportvereinen/Fitnessstudios ohne eigenen kartierbaren Ort statt); eine Kategorie mit strukturell unzuverlässiger Trefferquote anzubieten wäre schlechter, als sie wegzulassen.
- **`pflegedienst_24h` (24h-Pflegedienst/Betreuung rund um die Uhr, ergänzt 2026-07-29 nach weiterem Tester-Feedback)**: gegen das OSM-Wiki recherchiert — es gibt **kein eigenes Tag** dafür; korrekt wäre `ambulatory_care` + `opening_hours=24/7`, aber `opening_hours` wird bei Pflegediensten in der Praxis so gut wie nie gepflegt, eine reine Tag-Suche darauf liefert praktisch keine Treffer. Stattdessen ein **Namens-Musterfilter** auf denselben `ambulatory_care`-Treffern (`["name"~"24.?h|24.?stunden",i]`) — 24h-Anbieter/-Vermittler tragen "24h"/"24-Stunden" in aller Regel im eigenen Firmennamen (Branchenusus). Gleiche Kompromiss-Logik wie bei "Soziales Café", Label ebenfalls explizit mit "(geringe Datenlage)" markiert. Live gegen die echte Overpass-API verifiziert (nach anfänglichen 429/504 durch den ohnehin knappen Rate-Limit des öffentlichen Mirrors, 2 gleichzeitige Slots pro IP, siehe Betriebshinweis unten): die Suche lief für PLZ 10115/30 km fehlerfrei durch (0 Treffer) — bestätigt sowohl die korrekte Query-Syntax als auch die erwartete geringe Datenlage, kein Zeichen eines Fehlers im Code selbst.
- **Betriebshinweis**: Der öffentliche Overpass-Mirror antwortet bei schnell aufeinanderfolgenden Anfragen (z.B. Kategorie-Wechsel im Sekundentakt) gelegentlich mit einem Timeout, obwohl er grundsätzlich erreichbar ist — beim Testen dieser Funktion mehrfach beobachtet und durch einfaches erneutes Anfragen (bzw. den eingebauten Retry) bestätigt transient. Kein Zeichen für einen echten Ausfall; wird sauber als 502 mit deutscher Fehlermeldung an den Nutzer durchgereicht, nicht als 500-Crash.
- Router `app/routers/umkreissuche.py`: `GET /api/umkreissuche/kategorien` (Liste fürs Frontend-Select), `GET /api/umkreissuche?plz=&kategorie=&radius_km=` (5-stellige PLZ, Radius aus `{5,10,15,25,50}`, beides serverseitig validiert). Frontend: `lib/umkreissuche.ts` + `(app)/umkreissuche/page.tsx`, Nav-Eintrag unter "Verwaltung".
- **Favoriten sind bewusst je Nutzerkonto, nicht je Patient** (explizite Vorgabe des Betreibers) — ein gefundener Pflegedienst/Sanitätshaus/etc. ist typischerweise für alle vom Konto verwalteten Patienten gleichermaßen relevant, nicht auf einen einzelnen beschränkt. Modell `UmkreissucheFavorit` (`umkreissuche_favoriten`-Tabelle, Migration `a7c4e9f1b2d3`), `UniqueConstraint(nutzer_id, osm_type, osm_id)` verhindert Duplikate und macht `POST /api/umkreissuche/favoriten` idempotent (erneutes Merken desselben OSM-Objekts gibt den bestehenden Favoriten zurück statt einen Fehler zu werfen). Name/Adresse/Kontakt werden beim Merken kopiert (nicht live nachgeladen), damit ein Favorit auch anzeigbar bleibt, wenn Overpass gerade down ist oder der Eintrag in OSM verschwindet — das ist auch der Grund, warum jeder Umkreissuche-Treffer jetzt `osm_type`+`osm_id` mitliefert (eindeutiger, stabiler Schlüssel für "ist das schon gemerkt?"-Abgleich im Frontend). `GET/POST/DELETE /api/umkreissuche/favoriten[/​{id}]`, alle `require_login`-geschützt und implizit auf den eigenen Nutzer beschränkt (kein `patient_id`-Bezug irgendwo in diesem Datenfluss).
- **Rechtstexte aktualisiert (2026-07-28)**: `rechtstexte/Datenschutzerklaerung.txt` bekam eine neue Ziffer 11 ("Umkreissuche für Pflegedienste, Sanitätshäuser und weitere Einrichtungen"), die den Nominatim-/Overpass-Datenfluss offenlegt (nur PLZ+Kategorie+Radius werden übermittelt, serverseitig — die Nutzer-IP erreicht diese Dienste nie; kein AVV nach Art. 28 DSGVO möglich, da es sich um freie Gemeinschaftsinfrastruktur ohne Vertragspartner handelt, daher als eigenständige Drittübermittlung nach Art. 6 Abs. 1 lit. b/f DSGVO eingeordnet), alle Folgeziffern wurden entsprechend hochgezählt (12–18). `Nutzungsbedingungen.txt` bekam einen Feature-Bullet in Ziffer 2.1 plus einen Haftungsausschluss in Ziffer 11.6 für die Richtigkeit der von OSM stammenden Anbieterdaten. **Wichtig**: diese `.txt`-Dateien sind nur die Betreiber-Quelltexte — die tatsächlich angezeigten Rechtstexte liegen als `einstellungen`-Keys (`rechtstext_datenschutz`/`rechtstext_agb`) in der DB und werden manuell über die Backend-Einstellungen im Admin-UI gepflegt (siehe "Admin focus, self-registration & legal pages"); es gibt keinen automatischen Datei→DB-Sync wie beim Changelog. Der Betreiber muss die aktualisierten Texte nach diesem Änderungsdurchlauf selbst ins Admin-UI übertragen, damit sie live wirksam werden.
## Benachrichtigung bei Änderung eines Umkreissuche-Favoriten (pflegepilot_v3 only, 2026-07-29, T15)
Letzter Punkt aus dem ursprünglichen Tester-Feedback-Backlog: wenn sich Kontaktdaten eines gemerkten Umkreissuche-Treffers (Pflegedienst, Sanitätshaus, Wohnform, Therapie-/Reha-Einrichtung — siehe "Umkreissuche für lokale Pflege-Dienstleister" oben) in OSM ändern, wird der Kontoinhaber per E-Mail informiert.
- `app/umkreis_favoriten_watch.py`, dritte Ausnahme vom "mailto statt SMTP"-Prinzip (nach Passwort-Reset und T14/Katalog-Änderung) — es gibt keine Nutzeraktion, an die sich der Versand hängen ließe.
- **Läuft als echter wiederkehrender Hintergrundjob** (wie `reminder_scheduler.py`), nicht nur einmalig pro App-Start wie `katalog_watch.py` — ein OSM-Eintrag kann sich jederzeit ändern, nicht nur bei einem Deploy dieser App. **Bewusst nur einmal täglich**: der öffentliche Overpass-Mirror hat ein sehr knappes Rate-Limit — **real erlebt während der Entwicklung dieses Features: nur 2 gleichzeitige Slots pro IP** (`curl https://overpass-api.de/api/status`) —, eine häufigere Abfrage aller Favoriten aller Konten wäre unverhältnismäßig für eine kostenlose Gemeinschaftsinfrastruktur.
- **Sammel-Abfrage statt einer Anfrage je Favorit**: alle Favoriten desselben `osm_type` (node/way/relation) werden in möglichst wenigen Overpass-Anfragen per ID-Filter abgeholt (`node(id:1,2,3);`, siehe OSM-Wiki "Overpass QL – By element ID"), in Chunks von 100 IDs mit 2s Pause dazwischen — bei z. B. 50 gemerkten Favoriten sonst 50 statt 1-3 Anfragen pro Durchlauf.
- Gleiches Multi-Worker-Problem/-Lösung wie bei allen anderen Hintergrund-Schedulern dieser App: Postgres-Advisory-Lock (`519274863`) über die gesamte Thread-Lebensdauer.
- **Verglichene Felder**: Name, Straße, PLZ, Ort, Telefon, E-Mail, Webseite. Bei Abweichung wird der gespeicherte Favorit-Snapshot auf den neuen Stand aktualisiert (sonst würde dieselbe Änderung an jedem folgenden Tag erneut gemeldet) und eine E-Mail an den Kontoinhaber verschickt — mehrere geänderte Favoriten desselben Kontos werden zu einer einzigen Mail pro Durchlauf gebündelt statt mehrerer Einzelmails.
- **Bewusste Einschränkung**: ist ein Favorit in OSM komplett nicht mehr auffindbar (gelöscht), wird das nur geloggt, nicht dem Nutzer gemeldet — ein "verschwunden statt geändert"-Notify-Flow bräuchte eine zusätzliche Schutzmaßnahme gegen tägliche Wiederholungsmeldungen (z. B. ein neues Schema-Feld oder automatisches Löschen des Favoriten), was für den ursprünglichen Tester-Wunsch ("Kontaktdaten geändert") überkonstruiert wäre.
- **End-to-end gegen einen echten, produktiv gemerkten Favoriten verifiziert** (nicht nur synthetisch): Overpass-ID-Abfrage lief mit HTTP 200 durch, die live aus OSM gelesenen Werte stimmten mit dem gespeicherten Stand überein — kein Fehlalarm, keine E-Mail ausgelöst, Datensatz unverändert.
## Umkreissuche: Kategorie "24h-Pflegedienst" (pflegepilot_v3 only, 2026-07-29)
Zusätzliches Tester-Feedback nach Abschluss von T8/T10-T13: `pflegedienst_24h` als neue Kategorie in derselben `KATEGORIEN`-Registry (`app/umkreissuche.py`) — siehe dortigen Eintrag unter "Umkreissuche für lokale Pflege-Dienstleister" für die Details (kein eigenes OSM-Tag, Namens-Musterfilter statt Tag-Filter, "(geringe Datenlage)" im Label). Da die Frontend-Kategorieauswahl datengetrieben über `GET /api/umkreissuche/kategorien` läuft, war dafür keine einzige Frontend-Codezeile nötig — reine Backend-Registry-Ergänzung, gleiches Muster wie T11-T13.
## Landingpage-Überarbeitung + SEO (pflegepilot_v3 only, 2026-07-29)
Betreiber-Wunsch: die Startseite einladender gestalten, die Feature-Breite der App sichtbar machen ("was er für sein bisschen Geld alles bekommt"), dabei keine Backend-/Technik-Details offenlegen, für SEO optimieren und wo sinnvoll Admin-editierbar machen.
- **`(marketing)/page.tsx` von einer Client-Component (`"use client"`, React-Query-Hook) zu einer async Server Component umgebaut** — das war die Voraussetzung für echtes SEO: eine `"use client"`-Seite kann kein `generateMetadata()` exportieren (Next.js verlangt dafür eine Server Component), und ein clientseitig nachgeladener Seiteninhalt ist für Suchmaschinen-Crawler weniger zuverlässig als serverseitig bereits im initialen HTML vorhandener Text. Die Seite brauchte ohnehin keine Interaktivität (nur `<Link>`, keine Events/State) — der Umbau war deshalb reine Entfernung von `"use client"` plus Umstellung von `usePublicEinstellungen()` (Client-Hook) auf einen eigenen serverseitigen `fetch()`-Helper (`ladeOeffentlicheEinstellungen()`), den sowohl `generateMetadata()` als auch die Seite selbst aufrufen — Next.js dedupliziert identische `fetch()`-Aufrufe automatisch innerhalb desselben Requests, kein doppelter Roundtrip.
- **Neue Umgebungsvariable `API_INTERNAL_URL` (nicht `NEXT_PUBLIC_API_URL`) für diesen serverseitigen Fetch** — wichtiger Unterschied, der beim Bauen erst durch Nachschauen in der echten NAS-`.env` auffiel: `NEXT_PUBLIC_API_URL` ist dort bewusst **leer** (der externe Zugriff läuft über Pfad-Routing im Reverse-Proxy, der Browser ruft `/api/*` relativ zur selben Domain auf, siehe "Externer Zugriff über Nginx Proxy Manager" in der NAS-Deployment-Memory) — ein serverseitiger `fetch()` auf einen relativen Pfad hätte keine Basis-URL und würde fehlschlagen. Und selbst ein gesetzter Wert wäre falsch gewesen: `localhost:8100` ist nur der **host-seitig gemappte** Port, aus einem anderen Container (hier: `web`) heraus nicht erreichbar. Der korrekte Weg für Container-zu-Container-Kommunikation ist der interne Docker-Compose-Servicename (`API_INTERNAL_URL=http://api:5050` in `docker-compose.yml`, Default über `${API_INTERNAL_URL:-http://api:5050}` auch ohne `.env`-Eintrag) — gilt identisch für lokale Entwicklung und die NAS, da beide dieselbe `docker-compose.yml` mit denselben Servicenamen nutzen. Per direktem `node -e "fetch(...)"` im laufenden `web`-Container sowie durch einen echten DB-Testwert (`landing_seo_title`, hinterher wieder entfernt) end-to-end verifiziert. Der Fetch nutzt `cache: "no-store"` statt einer Revalidate-Frist — beim Testen blieb ein per DB-Testwert geänderter Seiteninhalt trotz `next: { revalidate: 300 }` und mehrfacher Anfragen im Abstand von Sekunden bis Minuten auf dem alten Stand (vermutlich eine Turbopack-Dev-Mode-Eigenheit), sodass ein Admin nach dem Speichern in den Backend-Einstellungen nicht zuverlässig sofort das Ergebnis gesehen hätte — bei so geringem Traffic auf einer einzelnen Landingpage ist der Verzicht auf Caching hier vernachlässigbar.
- **Feature-Liste erheblich erweitert** (von 6 auf 10 Kacheln) — deckt jetzt alle inzwischen gebauten Funktionen ab (Vollmacht/Patientenverfügung, Ummeldung, Umkreissuche mit Favoriten, Dokumenten-/eGK-Scan, Budget-Tracker, Krisen-Checklisten), nicht nur den Stand von Phase 3. Bewusst **keine** Technik-/Backend-Begriffe im Text (kein "OpenStreetMap", "Tesseract", "PostgreSQL" etc.) — nur nutzerorientierte Nutzenbeschreibung.
- **SEO-Metadaten**: `generateMetadata()` liefert Titel/Beschreibung/OpenGraph/Twitter-Card/kanonische URL, mit Fallback auf fest hinterlegte Standardtexte, falls die Einstellungen (noch) leer sind oder die API einmal nicht erreichbar ist (`try/catch`, gibt dann `{}` zurück statt die Seite abstürzen zu lassen). Zusätzlich ein `SoftwareApplication`-JSON-LD-Block (schema.org) mit Preis aus den echten `abo_preis_*`-Einstellungen, nicht hartkodiert.
- **`src/app/robots.ts` und `src/app/sitemap.ts` neu** (existierten vorher gar nicht) — `robots.ts` erlaubt explizit nur die öffentlichen Marketing-Seiten (`/`, `/impressum`, `/datenschutz`, `/agb`) und disallowed explizit jede bekannte `(app)`-Route; bewusst zwei explizite Listen statt eines einzigen `disallow:"/"` mit Allow-Ausnahmen, damit eine künftig neu hinzukommende `(app)`-Route nicht versehentlich indexiert wird, nur weil sie vergessen wurde, in eine Disallow-Liste einzutragen. Neue Env-Var `NEXT_PUBLIC_SITE_URL` (Default `https://meinpflegefuchs.de`) für kanonische URLs/OpenGraph/Sitemap-Domain.
- **Modularität ins Admin-Dashboard eingebaut, wie gewünscht — aber bewusst nur für Hero-Text und SEO-Meta, nicht für die Feature-Kacheln selbst**: vier neue `einstellungen`-Keys (`landing_hero_titel`, `landing_hero_text`, `landing_seo_title`, `landing_seo_description`), als neue Karte "Landingpage & SEO" in den Backend-Einstellungen (`admin/einstellungen/page.tsx`) editierbar — genau das bereits bestehende generische Key-Value-Muster (`einstellungen_lesen`/`einstellungen_speichern` in `routers/admin.py` nehmen ohnehin jeden beliebigen Key an, keine Backend-Änderung dort nötig), plus Aufnahme der vier neuen Keys in die `_PUBLIC_KEYS`-Allowlist von `/api/einstellungen/public`. Die zehn Feature-Kacheln selbst bleiben bewusst im Code fest verdrahtet (kein dynamischer Kachel-Editor/CRUD im Admin-UI) — sie sind 1:1 an echte App-Funktionen gebunden, ein frei editierbarer Marketingtext könnte sonst leicht von der tatsächlichen Funktion abweichen; der Aufwand für ein vollwertiges Kachel-CRUD (Icon-Auswahl, Reihenfolge, …) stünde zudem in keinem Verhältnis zu Marketingtext, der selten geändert wird.
- **Betriebs-Lehre aus dem Deploy dieser Änderung**: die bisherige Annahme "`docker compose up -d --force-recreate web` reicht immer" (siehe NAS-Deployment-Notizen) stimmte diesmal nicht — ein per DB-Testwert erwartbar geänderter Seiteninhalt blieb nach einem normalen Force-Recreate zunächst auf dem alten Stand hängen (vermutlich ein von Turbopack/Next.js gehaltener Fetch-Cache, der einen reinen Force-Recreate übersteht, kombiniert mit Docker Composes Standardverhalten, anonyme Volumes beim Neuerstellen vom alten Container zu übernehmen statt sie neu anzulegen). Tatsächlich wirksam war erst `docker compose build web` gefolgt von `docker compose up -d --force-recreate --renew-anon-volumes web`. **Vorsicht**: `--renew-anon-volumes` ohne vorherigen Rebuild reißt `node_modules` weg (liegt selbst in einem anonymen Volume, das nur beim Image-Build aus dem Image befüllt wird) — immer erst bauen, dann mit `--renew-anon-volumes` neu erstellen, nie isoliert. Für reine Code-Änderungen ohne diese Cache-Problematik reicht weiterhin das einfachere `--force-recreate` allein.
## Kalender-Export für Aufgaben (pflegepilot_v3 only, 2026-07-28)
Nicht aus dem Tester-Feedback, sondern eine Anschlussfrage des Betreibers zu T15/Kalenderthemen: pro fälliger Aufgabe zwei kleine Buttons ("Zum Kalender hinzufügen") auf der Aufgaben-Seite, mit den bekannten Apple-/Google-Icons (`IconBrandApple`/`IconBrandGoogle`, `@tabler/icons-react` — keine mehrfarbigen Marken-SVGs eingebettet, monochrome Icons reichen für die Wiedererkennung).
- **Ein iCal-Format deckt beide Anbieter ab** — Google Calendar kann `.ics`-Dateien direkt importieren, es brauchte also keine separate "Google-Integration": `GET /api/aufgaben/{aid}/ics` (`app/routers/aufgaben.py`) liefert ein minimales `VCALENDAR`/`VEVENT` (ganztägig am `faellig_am`-Datum, `DTEND` = Folgetag nach iCal-Konvention für Ganztags-Events), normal sessionauthentifiziert (kein Token/Abo-Mechanismus nötig, da es ein direkter Klick-Download ist, keine wiederkehrend abgerufene Abo-URL) — Ownership-Check via neuem `_owned_aufgabe_or_404()`-Helper (aus dem bereits existierenden Inline-Check in `erledigt()` extrahiert). Sonderzeichen (Komma/Semikolon/Backslash/Zeilenumbruch) werden über `_ics_escape()` nach RFC 5545 escaped; Zeilen-Folding (>75 Oktette) bewusst weggelassen, da für die kurzen Titel/Beschreibungen dieser App nicht praxisrelevant.
- **Der Google-Button braucht gar keinen Backend-Aufruf**: Googles öffentliches, unauthentifiziertes "Quick Add"-URL-Schema (`calendar.google.com/calendar/render?action=TEMPLATE&text=&dates=&details=`) wird rein clientseitig aus den bereits geladenen Aufgaben-Daten gebaut (`lib/aufgaben.ts: googleCalendarUrl()`), kein OAuth/API-Key/Google-Cloud-Projekt nötig.
- **Bewusst kein Abo-Feed** (kontinuierlich synchronisierender externer Kalenderabo-Link) — das wäre die teurere, im Gespräch verworfene Alternative gewesen (bräuchte einen eigenen, regenerierbaren Token pro Nutzerkonto, da Kalender-Clients Abo-URLs ohne Session-Cookie periodisch selbst abrufen). Aktuelle Lösung ist bewusst ein einmaliger Pro-Aufgabe-Export, kein laufend aktualisiertes Abo.
## PLZ→Ort-Autofill bei Nutzer- und Patienten-Anlage (pflegepilot_v3 only, 2026-07-28)
Sobald eine 5-stellige Postleitzahl eingegeben ist, wird das Ort-Feld automatisch übernommen — auf der Selbstregistrierung (Rechnungsanschrift, `AuthOverlay.tsx`), im "Neuer Patient"-Assistenten (`patienten/neu/page.tsx`, Step 2) und im Patient-Bearbeiten-Formular (`patienten/[id]/bearbeiten/page.tsx`).
**Ort ist ein von der PLZ abgeleitetes Feld, kein einmaliger Vorschlag** (Betreiber-Vorgabe): vollständige, bekannte PLZ → Ort wird übernommen, auch wenn vorher ein anderer Wert dort stand; unvollständige oder gelöschte PLZ → Ort wird automatisch geleert. Damit bleibt bei einem Vertipper nie ein zur PLZ nicht mehr passender Ort stehen, den man sonst manuell erst löschen müsste — Korrektur der PLZ genügt, der Ort folgt automatisch mit. Das Ort-Feld bleibt trotzdem ein normales, editierbares Textfeld (kein Dropdown/readonly) — für die seltenen Fälle, in denen die PLZ nicht in der Datenbank steht oder der Vorschlag falsch ist, kann der Nutzer weiterhin manuell eintragen; dieser manuelle Wert bleibt nur so lange bestehen, bis die PLZ das nächste Mal geändert wird.
- **Datenbasis statt Live-Geocoding**: anders als bei der Umkreissuche (dort bewusst Nominatim/Overpass live, da Umkreissuche echte Koordinaten braucht) ist PLZ→Ort eine stabile, sich praktisch nie ändernde Zuordnung — dafür passt eine gebündelte, lokale Datei besser als ein externer Live-Call bei jedem Tastenanschlag: schneller, kein Rate-Limit-Risiko, keine Abhängigkeit von einem Drittserver für einen so grundlegenden Formular-Baustein. `app/static_data/plz_orte.json` (~9.000 Einträge) + dünner Loader `plz_orte.py`, Quelle: GeoNames.org Postal-Code-Export für Deutschland (`download.geonames.org/export/zip/DE.zip`, CC BY 4.0).
- **Grosskunden-PLZ-Problem**: der GeoNames-Rohexport (~10.800 Zeilen) enthält neben normalen Wohnort-PLZ auch ca. 1.800-2.000 deutsche Grosskunden-PLZ (dedizierte Einzelfirma-Postleitzahlen, z. B. „10875" ausschliesslich für Mercedes-Benz-Gesellschaften in Stuttgart) — bei diesen steht im Ortsnamen-Feld ein Firmen-/Behördenname statt eines echten Orts. Über eine Firmennamen-Erkennung (Rechtsformen wie GmbH/AG/KG, Behördenbegriffe wie Finanzamt/Ministerium/Bezirksregierung, Kassen/Verbände/Kammern etc.) grösstenteils herausgefiltert — nicht perfekt (vereinzelte reine Regierungssitz-PLZ ohne echten Orts-Kandidaten, z. B. „01095" → Sächsische Staatskanzlei, können noch falsch zugeordnet sein), aber für eine Autofill-Komfortfunktion ausreichend, da der Vorschlag jederzeit überschreibbar ist.
- **Mehrdeutige PLZ** (ca. 2.000 Fälle, meist ländliche PLZ, die mehrere Nachbargemeinden/Ortsteile abdecken, z. B. Dresdner Stadtteile) lösen sich auf den **kürzesten** Kandidatennamen auf (meist der schlichte Orts-/Stadtname ohne Ortsteil-Zusatz) — bewusst kein Mehrfachauswahl-Dropdown, da für eine einfache Autofill-Funktion nicht gerechtfertigt.
- `GET /api/plz/{plz}` (`app/routers/plz.py`) ist **bewusst ohne Login-Pflicht** — die Selbstregistrierung braucht die Autofill-Funktion, bevor überhaupt ein Konto existiert; keine sensiblen Daten involviert (reine öffentliche Postleitzahl-Referenzdaten), gleiches Risikoprofil wie `/api/einstellungen/public`.
- Frontend: `lib/plz.ts: usePlzSync(plz, setOrt, aktiv=true)`. Der `aktiv`-Parameter existiert ausschliesslich fuer das Bearbeiten-Formular: dort ist beim Laden bereits eine vorhandene, potenziell korrekte Adresse im Formular (die nicht zwingend aus dieser PLZ-Datenbank stammt) — ohne Schutz wuerde das blosse Oeffnen der Seite den bestehenden Ort sofort mit dem Datenbank-Vorschlag ueberschreiben, noch bevor der Nutzer irgendetwas angefasst hat. Deshalb bekommt `usePlzSync` dort zusaetzlich einen `plzBeruehrt`-State (gleiches Muster wie das bereits bestehende `versichertennummerBeruehrt`), der erst per PLZ-Feld-`onChange` auf `true` gesetzt wird - die Sync-Logik (uebernehmen/leeren) greift dann erst ab der ersten echten Nutzereingabe. Die beiden Anlage-Formulare (Registrierung, "Neuer Patient") haben keine vorhandene Adresse zu schuetzen und lassen `aktiv` einfach auf dem Default `true`.
- Datei ist eine einmalige Momentaufnahme (Stand 2026-07-28) — Postleitzahlen ändern sich in Deutschland selten, aber nicht nie (Neubaugebiete, Gemeindefusionen); keine automatische Aktualisierung eingerichtet, bei Bedarf manuell gegen einen neuen GeoNames-Export re-generieren.
## Patientenverfügung-Generator (pflegepilot_v3 only, 2026-07-28)
Fünfter Dokument-Generator neben Widerspruch/Musterbrief/Ummeldung/Vollmacht — auf ausdrücklichen Wunsch als **mehrstufiges Interview** statt eines einzelnen Formulars, weil vor der PDF-Erzeugung inhaltlich einiges abgeklärt werden muss (in welchen Situationen soll die Verfügung gelten, welche medizinischen Maßnahmen in diesen Situationen gewünscht/abgelehnt werden, Organspende, Bezug zu einer bereits hinterlegten Vollmacht etc.).
- **Formulierungen sind wortgetreu aus der offiziellen Broschüre "Patientenverfügung" des Bundesministeriums der Justiz übernommen** (bmj.de, Abschnitt 2 "Textbausteine Patientenverfügung", Stand Januar 2023 laut dem PDF selbst) — siehe `app/static_data/patientenverfuegung_textbausteine.py` für die vollständige Begründung, warum das zulässig ist: die Arbeitsgruppe "Patientenautonomie am Lebensende" hat diese Textbausteine ausdrücklich als Formulierungshilfen zur **wörtlichen Übernahme** durch Bürgerinnen und Bürger veröffentlicht — genau das ist der Verwendungszweck hier, kein Zitat im Sinne des Urheberrechts. Nicht selbst formuliert, sondern per PyMuPDF (`fitz`) aus dem offiziellen PDF extrahiert und 1:1 geprüft, um keine eigene, rechtlich unbelastbare Formulierung unterzuschieben.
- **Struktur folgt der von der Broschüre selbst empfohlenen Gliederung** (Abschnitt 1.11 "Empfohlener Aufbau"): Eingangsformel → Situationen (2.2) → Festlegungen zu ärztlichen Maßnahmen (2.3, alle 8 Kategorien: Lebenserhaltung, Wiederbelebung A+B, Schmerzbehandlung, künstliche Ernährung, Beatmung, Dialyse, Antibiotika, Blut) → Ort/Beistand (2.4) → Schweigepflichtentbindung (2.5) → Verbindlichkeit/Auslegung (2.6) → Vorsorgevollmacht-/Betreuungsverfügung-Bezug (2.7) → Organspende (2.9) → Schlussformel (2.10) → Schlussbemerkungen (2.11, immer enthalten) → Beratung (2.12) → optionales Freifeld für spätere ärztliche Bestätigung der Einwilligungsfähigkeit (2.13).
- **Fortlaufende statt fester Abschnittsnummerierung** (`build_patientenverfuegung_text()` in `app/briefe_text.py`, Variable `abschnitt`): mehrere Abschnitte sind optional (nur bei entsprechenden Angaben, z.B. kein Ort/Beistand-Abschnitt wenn nichts dazu angegeben wurde) — mit fest einprogrammierten Ziffern hätte eine leere Angabe zu Nummerierungslücken geführt (z.B. "2. ... 5. ..." ohne 3./4.), was in einem rechtlich bedeutsamen Dokument wie eine Fehlstelle wirken würde. Gefunden und gefixt noch während der Verifikation dieser Session, nicht erst durch Nutzer-Feedback.
- **Kein Fensterkuvert-Layout, kein Unterzeichner-Wechsel bei hinterlegter Vollmacht** — anders als die anderen vier Generatoren ist dies kein Brief an einen Empfänger, sondern eine eigenständige persönliche Erklärung. Neues, eigenes PDF-Template `app/pdf_templates/patientenverfuegung.html` (kein Absender-/Empfänger-Fenster, dafür Titel-Layout). Eine Patientenverfügung drückt begrifflich zwingend den eigenen Willen der betroffenen Person aus und kann nicht durch eine bevollmächtigte Person an ihrer Stelle unterschrieben werden — die `_unterzeichner()`/Vollmacht-Wechsel-Logik aus `routers/briefe.py` wird hier bewusst nicht verwendet, es unterschreibt immer nur der Patient selbst.
- **Layout-Fix während der Verifikation**: die optionale ärztliche Bestätigung (2.13) wurde zunächst in den Fließtext eingebettet und erschien dadurch *vor* der eigentlichen Patienten-Unterschrift im PDF — inhaltlich falsche Reihenfolge (die eigene Unterschrift gehört direkt nach der Erklärung, der ärztliche Anhang ist ein nachgelagerter, optionaler Zusatz). Gefixt, indem der Baustein aus `build_patientenverfuegung_text()` entfernt und stattdessen als eigener, bedingter Block *nach* dem `unterschriften`-Div direkt im Template gerendert wird (`aerztliche_bestaetigung`-Flag an `render_pdf()`).
- `Antrag.typ` erweitert um `'patientenverfuegung'` (Migration `b8d3e5f7a1c9`), nutzt denselben autorisierungsgeprüften Download-Endpunkt wie alle anderen Briefe (`GET /api/patienten/{pid}/antraege/{aid}/pdf`) — keine neue Speicher-/Download-Infrastruktur nötig.
- `GET /api/patientenverfuegung/textbausteine` liefert alle Options-Daten fürs Interview (Situationen, Maßnahmen-Kategorien mit Formulierungen, Vertrauensperson-/Organspende-Optionen) — normal `require_login`-geschützt (anders als der öffentliche PLZ-Endpunkt, da dies nur innerhalb der eingeloggten App gebraucht wird).
- **Kein Anspruch auf Vollständigkeit oder Rechtsberatung**: Fußzeile im generierten PDF weist explizit auf die Quelle (BMJ-Broschüre) hin und stellt klar, dass dies keine rechtliche oder ärztliche Beratung ersetzt — eine Patientenverfügung ist nur wirksam, wenn sie tatsächlich der freie Wille der volljährigen, einwilligungsfähigen Person ist (§ 1901a BGB); das kann Software nicht prüfen. Im Ergebnis-Bildschirm nach Erstellung wird zusätzlich auf das Zentrale Vorsorgeregister (vorsorgeregister.de, Bundesnotarkammer) als Möglichkeit hingewiesen, die Verfügung im Ernstfall auffindbar zu machen — die App selbst registriert dort nichts automatisch.
## Dashboard: Patienten-Kacheln + klickbare offene Anträge (pflegepilot_v3 only, 2026-07-29)
Betreiber-Feedback: das Dashboard soll als Einstiegspunkt mehr Information bieten. Zwei Änderungen an `GET /api/dashboard` (`app/routers/dashboard.py`) und `(app)/dashboard/page.tsx`:
- **"Zuletzt bearbeitete Patienten" (reine Namensliste) ersetzt durch "Patienten im Überblick"**: kleine anklickbare Kachel pro Patient (Name, Pflegegrad-Badge, Status, plus Badges "X Anträge offen"/"Y Aufgaben offen" — nur wenn >0, sonst kein Badge). Datenquelle: `DashboardOut.patienten_uebersicht` statt `letzte_patienten` (Feld entfernt, keine anderen Verwender im Frontend).
- **"Offene Anträge"-Kachel ist jetzt klickbar** (Tester-/Betreiber-Feedback: die reine Zahl war nicht klickbar) und springt per Anchor-Link (`#offene-antraege`) zu einer neuen Sektion "Offene Anträge nach Patient" darunter — je Patient eine Zeile mit dem am längsten wartenden offenen Antrag als Vorschau plus Gesamtzahl ("+N weitere"), anklickbar zur Patientenakte.
- **Bewusst gedeckelt** (`MAX_PATIENTEN_KACHELN=6`, `MAX_OFFENE_ANTRAEGE_GRUPPEN=5` in `dashboard.py`), damit das Dashboard nicht überladen wirkt — explizite Vorgabe aus dem Feedback ("soll aber nicht zu voll werden"). Wer mehr Patienten/offene Anträge hat, findet den Rest über `/patienten` bzw. die einzelne Patientenakte.
- Aggregation läuft in Python (nicht als SQL-GROUP-BY), da die Datenmengen pro Konto klein sind und so sowohl die Kacheln als auch die Gruppierung aus denselben zwei bereits geladenen Listen (offene Anträge, offene Aufgaben) gespeist werden können, ohne zusätzliche Datenbank-Rundfahrten.
- Kleiner Nebenfund beim Anfassen von `models.py`: die `CheckConstraint`-Zeichenkette für `Antrag.typ` war seit der Patientenverfügung-Migration nicht mehr aktuell (fehlte dort, `vollmacht` war schon nachgezogen). Folgenlos, da diese App Schema ausschließlich über Alembic-Migrationen provisioniert (kein `Base.metadata.create_all()` im Code) — trotzdem korrigiert, damit der Code nicht in die Irre führt.
## Dokumenten-Scan (pflegepilot_v3 only, 2026-07-29)
Betreiber-Feedback: die Zielgruppe ist zu ca. 80% Tablet-/Handy-Nutzer/innen, die oft keinen eigenen Scanner besitzen bzw. keinen PC mehr für Dateiverwaltung nutzen. Neben dem bestehenden Datei-Upload (`hochladen`) gibt es jetzt eine zweite Option im "Dokumente"-Bereich der Patientenakte: **Kamera-Direktaufnahme mit serverseitiger Kantenerkennung/Perspektivkorrektur**, mehrere Seiten zu einem PDF zusammengefasst.
- **Bewusst serverseitig (Python/OpenCV), nicht clientseitig (JavaScript/WASM)** — eine clientseitige Lösung (z. B. OpenCV.js) müsste jedem Handy vor der ersten Nutzung ~8-9 MB WASM zumuten; serverseitig lädt das Handy nur das rohe Foto hoch, die Rechenarbeit läuft auf dem Server. Direkte Konsequenz aus der genannten Zielgruppe.
- `app/dokument_scan.py`: klassisches OpenCV-Rezept (Kontur des Dokuments finden → auf 4 Eckpunkte reduzieren → Perspektive entzerren). **Zwei Stufen der Kulanz**, bevor der Upload jemals blockiert wird: (1) findet sich eine ausreichend große Kontur, die sich aber nicht sauber auf 4 Eckpunkte reduzieren lässt, wird ersatzweise deren umschließendes gedrehtes Rechteck (`cv2.minAreaRect`) verwendet; (2) findet sich überhaupt keine ausreichend große Kontur (schlechtes Licht, unklarer Hintergrund), wird das Originalfoto unverändert weiterverwendet — explizite Betreiber-Entscheidung (Alternative wäre manuelles Nachjustieren der 4 Ecken gewesen, bewusst nicht für den ersten Wurf).
- **2026-07-29 nachgebessert**, nachdem der Betreiber die Kantenerkennung mit echten Fotos als mangelhaft zurückgemeldet hat: Median-basiertes Auto-Canny statt fixer Schwellenwerte 50/150 (robuster über unterschiedliche Belichtung hinweg), bilateraler statt Gauß'scher Weichzeichner (glättet Papierstruktur, ohne die Kante zu verwischen), `MORPH_CLOSE` schließt kleine Lücken in der erkannten Kante (häufig durch Schatten verursacht), mehrere Epsilon-Werte für `approxPolyDP` statt nur einem festen Wert, Arbeitsauflösung von 500px auf 700px erhöht (zu grob für komplexere Hintergründe). Mit einem realistischeren synthetischen Test (Bildrauschen + schwacher Kontrast, den die alte Version nicht erkannt hätte) verifiziert — die eigentliche Bewährungsprobe sind aber weiterhin echte Handyfotos unter echten Lichtverhältnissen, dafür fehlt weiterhin eine größere Testreihe. Die Erkennungsschwelle (`MIN_KONTUR_ANTEIL = 0.2`) bleibt der erste Regler, an dem bei anhaltenden Problemen gedreht werden sollte.
- Die Bezeichnung eines Scans lautet jetzt `{Scan-Datum} ({Seitenzahl} Seiten)` (z. B. „29.07.2026 (2 Seiten)“) statt generisch „Scan (2 Seiten)“ — Betreiber-Feedback: bei mehreren Scans desselben Dokumenttyps (z. B. mehrere „Kopie eines Bescheids“) waren die Einträge in der Liste sonst nicht unterscheidbar.
- **Mehrseitig**: mehrere per Kamera aufgenommene Fotos werden zu einem gemeinsamen PDF zusammengefasst (`bilder_zu_pdf()`, Pillow `save_all=True`) — eine Scan-Session kann mehrere Seiten eines Dokuments sein (z. B. ein mehrseitiger Bescheid). Bewusste Betreiber-Entscheidung, da einzelne Seiten für echte Behörden-/Arztdokumente unrealistisch gewesen wären.
- **Kein HEIC-Support** — weder OpenCV noch Pillow können HEIC ohne Zusatzbibliothek dekodieren. Unkritisch für den eigentlichen Kamera-Direktaufnahme-Flow (iOS liefert bei "Foto aufnehmen" über ein Web-`<input capture>` immer JPEG, unabhängig von der Kamera-Einstellung des Geräts) — nur relevant, falls jemand statt der Live-Aufnahme ein bestehendes HEIC-Foto aus der Mediathek wählt; würde dann mit einer klaren 400-Fehlermeldung abgelehnt, nicht stillschweigend falsch verarbeitet.
- Neuer Endpunkt `POST /api/patienten/{pid}/dokumente/scan` (mehrere Dateien unter demselben Formularfeld `dateien`) ergänzt, nicht ersetzt den bestehenden `POST /api/patienten/{pid}/dokumente` (Einzeldatei-Upload beliebigen Formats bleibt für PDFs/Word-Dokumente etc. bestehen) — beide erzeugen dieselbe `Dokument`-Zeile mit `typ`/`bezeichnung`, dieselbe autorisierte Download-Route.
- Frontend: `components/DokumentScan.tsx` — Kamera-Button sammelt Seiten client-seitig (Thumbnail-Vorschau, einzeln entfernbar) und lädt erst beim expliziten "Fertig, hochladen" alle gesammelten Fotos in einer Anfrage hoch, statt bei jedem Foto einzeln einen Request zu senden.
- Neue Abhängigkeit `opencv-python-headless` (keine GUI-Bibliotheken wie libGL nötig, die der Container ohnehin nie nutzt) — braucht ein `docker compose build api` (nicht nur `restart`) beim Deploy, da neue Python-Pakete zur Image-Bauzeit installiert werden.
## System-Backup im Admin-Dashboard (pflegepilot_v3 only, 2026-07-29)
Aus einem Sicherheitsgespräch mit dem Betreiber: Datenbank-Einträge sind für ihn als Admin direkt einsehbar (kein Feld-Verschlüsselungs-Feature) — das ist der Standardfall und für sich genommen kein DSGVO-Problem (Art. 32 DSGVO ist risikobasiert, Zugriff ist bereits auf den Betreiber selbst beschränkt und in der Datenschutzerklärung Ziffer 7 offengelegt). Das eigentlich relevante Risiko-Szenario ist ein Totalverlust des NAS — dagegen hilft nur ein 1:1-Backup außerhalb des Geräts, kein Verschlüsselungsfeature.
- **Ein Backup ist ein `.tar.gz`-Archiv** mit `database.sql` (kompletter `pg_dump`, Klartext-SQL statt Custom-Format für universellere Wiederherstellbarkeit ohne Versions-Zwang bei `pg_restore`) plus `uploads/` (1:1-Kopie des kompletten Upload-Verzeichnisses aller Konten — Scans, Briefe/PDFs, Fotos). Bewusst **kein** Betroffenenrechte-Export wie das bestehende DSGVO-Export-Feature (das ist auf einen Patienten beschränkt) — dies ist ein System-Backup über alle Konten hinweg, ausschließlich für den Betreiber.
- **Eigenes Docker-Volume** (`pflegepilot_v3_backups`, gemountet unter `/app/app/backups`) statt eines Unterordners von `uploads_dir` — bewusste Trennung, damit ein Backup-Archiv nie als "Patientendokument" durchgehen kann.
- **Manueller Button + automatisch täglich**, beides im Admin-Dashboard (Betreiber-Entscheidung nach Rückfrage): `app/backup_scheduler.py` folgt exakt dem Muster von `reminder_scheduler.py` (Postgres-Advisory-Lock über die gesamte Thread-Lebensdauer, eigene Lock-ID `412857930`, stündlicher Check). Es gibt bewusst **keine eigene "letztes Backup war am..."-Spalte** — die Quelle der Wahrheit ist schlicht der Zeitstempel des jüngsten vorhandenen Backup-Dateinamens (`backup.liste_backups()`); ist der nicht von heute, wird eines erstellt.
- **Rollierende Aufbewahrung der letzten 5** (Betreiber-Entscheidung "3-5"), sowohl nach dem automatischen als auch nach dem manuellen Erstellen aufgeräumt (`alte_backups_aufraeumen()`) — verhindert unbegrenzt viele Vollkopien aller Patientendaten auf der Platte.
- **Erstellen und Herunterladen werden im Admin-Audit-Log protokolliert** (gleiche Begründung wie beim DSGVO-Export: ein vollständiger Datenexport über alle Konten ist ein Vorgang, der nachvollziehbar bleiben sollte) — Erstellen braucht wie bei allen anderen Admin-Aktionen eine Begründung (`BegruendungModal`), Herunterladen wird automatisch mit fester Begründung "Download durch Admin" geloggt, ohne den Download durch ein zusätzliches Eingabefeld zu bremsen. Löschen eines Backups wird bewusst **nicht** geloggt (reine Aufräumaktion, kein Datenexport).
- **Dateinamen-Validierung gegen ein festes Muster** (`backup_YYYYMMDD_HHMMSS.tar.gz`) im Download-/Lösch-Endpunkt — Schutz vor Path Traversal über einen vom Client frei wählbaren Dateinamen.
- **Neue Abhängigkeit im Image**: `postgresql-client` (Dockerfile) für die `pg_dump`-Binary — braucht ein `docker compose build api`, nicht nur `restart`, plus `mkdir -p .../backups` im selben `RUN`-Schritt wie `uploads` (sonst würde das neue Docker-Volume beim ersten Mount mit falschem Owner initialisiert, da der `chown -R appuser` davor läuft).
- **pg_dump-17/psql-16-Versionsunterschied — ursprünglich als "harmloser Schönheitsfehler" eingeschätzt, dann durch echten Test als echter Blocker entlarvt**: Das Debian-Repository des `python:3.12-slim`-Basisimages liefert `postgresql-client` in Version 17, der DB-Server läuft auf Version 16 (`postgres:16`-Image). Ein mit pg_dump 17 erzeugter Dump enthält eine `SET transaction_timeout = 0;`-Zeile, die es in Postgres 16 noch nicht gibt. Solange nur der Dump selbst mit einem normalen (nicht-transaktionalen) `psql`-Lauf angeschaut wurde, blieb das folgenlos (Zeile erzeugt nur eine Warnung, der Rest des Dumps spielt sich korrekt ein) — das war die ursprüngliche Einschätzung unten im Restore-Abschnitt. **Das stimmte nur so lange, wie kein `--single-transaction --set ON_ERROR_STOP=1` im Spiel war.** Der eigentliche Restore-Feature (siehe unten) nutzt genau das für Atomarität — und unter dieser Kombination bricht die eine `SET`-Zeile die komplette Transaktion sofort ab, sodass **gar nichts** wiederhergestellt wird, nicht nur eine kosmetische Meldung erscheint. Das wurde erst durch einen echten, vom Betreiber ausdrücklich autorisierten Restore-Testlauf auf der NAS-Testinstanz entdeckt (nicht durch Code-Review vorhersehbar) — siehe `_dump_kompatibel_machen()` im Restore-Abschnitt für den tatsächlichen Fix. **Lehre für diese Codebasis**: ein als "kosmetisch" abgetaner Versionsunterschied kann durch eine spätere, unabhängige Änderung (hier: `--single-transaction` für ein ganz anderes Feature) nachträglich zu einem echten Blocker werden — bei jeder neuen Verwendung von `pg_dump`/`psql`-Ausgaben erneut prüfen, ob die bisherige Einschätzung noch zutrifft, statt sie unverändert zu übernehmen.
## Restore aus System-Backup (pflegepilot_v3 only, 2026-07-29)
Ergänzung zum Backup-Feature (siehe oben): "wie kriege ich die ZIP-Backup-Datei nach einem Totalausfall wieder ins System" — per Single-Upload im Admin-Dashboard, das Script übernimmt den Rest. `app/backup.py: restore_backup(archiv_pfad)`, Endpoint `POST /api/admin/backups/restore` (`archiv`: Datei-Upload, `begruendung`: Pflichtfeld wie bei jeder Admin-Aktion).
- **Reihenfolge bewusst so, dass nichts Destruktives passiert, bevor die Datei nicht minimal als gültig bestätigt ist**: 1) Archiv in ein Temp-Verzeichnis entpacken (`tarfile.extractall(..., filter="data")` — Pfad-Traversal-Schutz), `database.sql` muss existieren, sonst Abbruch ohne jede Änderung am laufenden System. 2) **Erst danach** eine Sicherheitskopie des aktuellen Zustands anlegen (Betreiber-Entscheidung: schützt vor einem Restore mit versehentlich falscher/veralteter Datei — der Dateiname der Sicherheitskopie wird in der API-Antwort und im Audit-Log-Eintrag mitgeliefert). 3) Datenbank per `psql --single-transaction --set ON_ERROR_STOP=1` einspielen — entweder komplett oder gar nicht. 4) `alembic upgrade head` erneut ausführen, falls seit dem Backup-Stand neue Migrationen zum aktuell laufenden Code hinzukamen. 5) Uploads-Verzeichnis durch den Inhalt aus dem Archiv ersetzen. 6) `engine.dispose()`, damit nachfolgende Requests garantiert frische DB-Verbindungen aufbauen.
- **Zwei echte Bugs, beide erst durch einen echten Restore-Testlauf auf der NAS-Testinstanz gefunden** (nicht durch Code-Review oder isolierte Unit-Tests vorhersehbar — beide betreffen die Interaktion mit der echten Docker-/Postgres-Umgebung):
1. **`transaction_timeout`-Blocker** (siehe oben im Backup-Abschnitt für die volle Herleitung): `_INKOMPATIBLE_SET_MUSTER`/`_dump_kompatibel_machen()` in `backup.py` entfernt die `SET transaction_timeout = ...;`-Zeile aus dem extrahierten Dump, bevor er an `psql` geht — gezielter Fix statt der (für einen einzigen Zeilen-Bug unverhältnismäßigen) Alternative, ein zweites PGDG-Apt-Repository für exakte Versions-Parität einzurichten.
2. **Uploads-Verzeichnis ist ein Docker-Volume-Mountpunkt**: der ursprüngliche Code rief `shutil.rmtree(settings.uploads_dir)` gefolgt von `shutil.copytree(...)` auf — schlug mit `OSError: [Errno 16] Device or resource busy` fehl, weil ein Mountpunkt selbst (anders als sein Inhalt) nicht gelöscht/neu angelegt werden kann. Gefixt, indem nur der **Inhalt** des Verzeichnisses geleert wird (`os.listdir()` + einzeln `rmtree`/`remove` je Eintrag), der Mountpunkt selbst aber bestehen bleibt, dann die neuen Dateien per `shutil.move()` hineingeschoben werden.
- **Vollständig end-to-end gegen die echte NAS-Testinstanz verifiziert** (mit expliziter Betreiber-Freigabe für einen echten — nicht nur simulierten — Restore-Test, nachdem Claude Codes eingebauter Sicherheits-Klassifizierer den ersten direkten Versuch dieses destruktiven Befehls blockiert hatte und stattdessen nachgefragt wurde): ein Test-Marker-Wert in `einstellungen` sowie die Patientenanzahl/der Alembic-Migrationsstand wurden vor und nach dem Restore verglichen, zusätzlich die tatsächlichen Datei-Inhalte des wiederhergestellten `uploads/`-Verzeichnisses stichprobenartig geprüft — alle korrekt. Nach erfolgreichem Test wurden die dabei automatisch erzeugten Sicherheitskopien sowie der Test-Marker-Eintrag wieder von der NAS entfernt.
- **Ein Restore aus einem *älteren* Backup lässt den Datenbank-Zustand während des Wiederherstellens serverseitig ungeschützt** (kein Wartungsmodus/Sperre für parallele Requests) — bewusst kein zusätzlicher Aufwand dafür, da diese Funktion für den seltenen "Totalausfall"-Fall gedacht ist, nicht für den laufenden Betrieb; ein Restore sollte praktisch immer in einem Wartungsfenster ausgeführt werden.
## eGK-Scan / OCR-Kartenerfassung (pflegepilot_v3 only, 2026-07-29, T8)
Letzter offener Punkt aus dem Tester-Feedback (siehe `Feedbackfromtester.md`): Krankenkassenkarte abfotografieren statt Versichertennummer/Krankenkasse/Name manuell abzutippen. Vier Produktentscheidungen vorab mit dem Betreiber abgestimmt (`AskUserQuestion`):
- **Tesseract OCR statt Cloud-Vision-API**: kostenlos, läuft offline im Container, kein Account/API-Key/Zahlungsmethode nötig (gleiche Grundsatzentscheidung wie OSM statt Google Places bei der Umkreissuche), Kartenfoto verlässt nie den eigenen Server. Kehrseite: spürbar geringere Erkennungsgenauigkeit als eine Cloud-Lösung, insbesondere bei schlechtem Licht/Winkel — bewusst in Kauf genommen.
- **Nur GKV (elektronische Gesundheitskarte)**, keine PKV-Karten: die eGK hat ein von gematik/GKV-Spitzenverband standardisiertes Layout (Versicherten-ID immer 1 Buchstabe + 9 Ziffern, feste Feldbezeichnungen "Name"/"Vorname"), das eine regelbasierte Erkennung überhaupt erst realistisch macht — PKV-Karten haben kein einheitliches Layout.
- **Alle fünf möglichen Felder werden übernommen** (Vorname, Nachname, Geburtsdatum, Versichertennummer, Krankenkasse), nicht nur die beiden Versicherungsfelder — Betreiber-Vorgabe: der Scan-Button sitzt deshalb in Schritt 1 des "Neuer Patient"-Assistenten (persönliche Daten), nicht erst in Schritt 3 (Krankenversicherung), damit ein einziger Scan beide Schritte vorausfüllt. Alle Felder bleiben normal manuell überschreibbar, eine verpasste/falsche Erkennung blockiert nie die Nutzung.
- **Verfügbar sowohl im "Neuer Patient"-Assistenten als auch im Patient-Bearbeiten-Formular** (z. B. bei Kassenwechsel eines bestehenden Patienten).
**Technisch**:
- `app/egk_ocr.py`: Tesseract (`pytesseract`, deutsches Sprachpaket) liest den Kartentext, danach regelbasierte Feld-Extraktion per Regex (Versicherten-ID: `[A-Z]\d{9}`; Geburtsdatum: `TT.MM.JJJJ`) und Label-Zeilenerkennung ("Name"/"Vorname" gefolgt vom Wert in derselben oder der nächsten Zeile). Die Kartenkontur-Erkennung/Entzerrung vor der OCR wird **1:1 von `dokument_scan.erkenne_und_entzerre()` wiederverwendet** statt eine zweite Kantenerkennung zu schreiben — eine eGK ist geometrisch nur ein kleineres Rechteck als ein A4-Dokument, dieselbe Heuristik passt.
- **Krankenkassen-Erkennung per Fuzzy-Matching** (`difflib.SequenceMatcher`) gegen die bestehende `krankenkassen.py`-Stammdatenliste, da der Kassenname auf der Karte oft als Logo statt als OCR-lesbarer Text gedruckt ist. **Ein echter Fehltreffer wurde beim Verifikationstest mit einem synthetischen Testbild gefunden und behoben, bevor das Feature als fertig galt**: eine kurze, komplett unpassende Zeile (Testname "Mustermann") wurde mit einem zu niedrigen Cutoff fälschlich als "Barmer" erkannt. Gefixt mit zwei Sicherungen: (1) ein Längenunterschied von mehr als 4 Zeichen zwischen OCR-Zeile und Kandidatenname wird gar nicht erst verglichen (SequenceMatcher-Werte werden bei sehr unterschiedlich langen Strings unzuverlässig), (2) ein fester Mindest-Punktzahl-Schwellenwert (`_KASSE_MIN_PUNKTZAHL = 0.72`) statt "bester Kandidat, egal wie schwach" — lieber kein Treffer als ein falscher.
- `POST /api/egk/scan` (`app/routers/egk.py`), 1-2 Fotos (Vorder-/Rückseite), `require_login`-geschützt, **bewusst nicht patientengebunden** (kein `pid` im Pfad) — wird sowohl vor der Patientenanlage (wo noch keine Patienten-ID existiert) als auch beim Bearbeiten genutzt. **Keine Persistierung**: weder das Kartenfoto noch das Erkennungsergebnis werden serverseitig gespeichert, die Response füllt ausschließlich clientseitig Formularfelder vor.
- Frontend: `components/EgkScan.tsx` (Kamera-Capture nach demselben `capture="environment"`-Muster wie `DokumentScan.tsx`, max. 2 Fotos), eingebaut in `patienten/neu/page.tsx` (Step1) und `patienten/[id]/bearbeiten/page.tsx`.
- Neue Abhängigkeiten: `tesseract-ocr` + `tesseract-ocr-deu` (Dockerfile, apt), `pytesseract` (requirements.txt) — braucht ein `docker compose build api`, nicht nur `restart`.
- **Ohne echte Testfotos verifiziert** (wie ursprünglich beim Dokumenten-Scan) — nur ein synthetisches Testbild mit bekanntem, sauberem Text stand zur Verfügung. Die Feld-Heuristiken (insb. Name/Vorname-Zeilenerkennung, die auf dem exakten Aufdruck-Wortlaut der eGK beruht) sollten nach den ersten echten Nutzungen erneut geprüft werden — gleiche Lehre wie beim Dokumenten-Scan ("Kantenerkennung ist mangelhaft" → Nachbesserung erst nach echten Fotos möglich).
## Email: mailto hand-off, not SMTP
There is **no server-side email sending anywhere in the app** — no SMTP client, no credentials, no relay. This was a deliberate removal (not a missing feature) done ahead of Docker containerization, because post-Docker the operator (Daniel) is the sole admin with no per-role differentiation to lean on — any email solution had to work identically for every account, not just an admin-configured one. `mailto:` is the "universal command": every OS/browser resolves it to the user's own default mail client (iOS → Mail, Android → Gmail/Outlook/whatever is set as default, desktop → whatever is set as default), with zero server involvement and zero secrets to manage in the container.
@@ -151,6 +363,8 @@ There is **no server-side email sending anywhere in the app** — no SMTP client
A per-account "what's new" system tells a user, once per account, what changed since they last looked — needed now that the app ships as versioned Docker images rather than one continuously-running dev process (see "Docker deployment" above).
**Incident 2026-07-28**: `changelog_seed.py` on the NAS had two historical entries (`"1.2.0" – "Checkliste, Leistungsauswahl, Vollmacht und mehr"` dated 2026-07-27, and `"Version 1.0.1" – "Klein-Anpassungen"`) that were never present in the git-tracked copy of this file — the same class of NAS/git divergence documented for `pdf_templates/brief.html` (see the NAS-deployment memory). A new local entry was written using version `"1.2.0"` without first diffing against the NAS, which would have silently never synced (since `sync_changelog()` only inserts *missing* versions, an already-used version string is a silent no-op, not an overwrite or an error) — caught before deploying by comparing the live `GET /api/changelog` output against the local file. Fixed by restoring both missing historical entries into git (so the file is a complete record again) and renumbering the new entry to `"1.3.0"`. **Lesson**: before adding a new changelog entry, always fetch the current `GET /api/changelog` (or check the DB) rather than assuming the local `changelog_seed.py` is the complete history — the DB is the runtime source of truth and can contain entries the git file never had, exactly like the `brief.html` case.
**Policy: every feature/change from here on gets a changelog entry** (either a new `changelog.py` entry, or an admin-added one via the UI described below), not just a commit message and a `CLAUDE.md` note — the two aren't a substitute for each other. `CLAUDE.md` is for future-Claude/developers; the changelog is for end users. If you ship a user-visible change, add the entry as part of that same piece of work, not as an afterthought.
- **Two-layer source of truth**: `changelog.py` is the developer-authored seed (same static-module pattern as `katalog.py`/`krankenkassen.py`) — a `CHANGELOG` list of `{version, datum, titel, punkte}` entries, always kept **newest-first**. The actual runtime source of truth is the DB table `changelog_eintraege` (`id, version, datum, titel, punkte` — `punkte` stored as a JSON-encoded string list). On every `init_db()` run, any `changelog.py` entry whose `version` isn't yet in the DB gets inserted (`for eintrag in CHANGELOG: ... INSERT ... if not already present`) — a one-way sync, not a mirror: editing an already-synced entry via the admin UI (below) does **not** get overwritten back from `changelog.py` on the next restart, since the sync only inserts missing versions, never updates existing ones. **To ship a release note tied to a real code change: add one new entry to the top of `CHANGELOG` in `changelog.py` and nothing else** — it'll sync into the DB on next startup. For a quick announcement or correction that doesn't need a deploy, use the admin UI instead (below).
+187
View File
@@ -0,0 +1,187 @@
# Tester-Feedback: Feature-Backlog
Quelle: `Feedbackfromtester.txt` (unstrukturierte Rohnotizen eines Testers). Diese Datei fasst die inhaltlichen Punkte zu thematischen Gruppen zusammen und ordnet sie nach geschätztem Umsetzungsaufwand. Betrifft ausschließlich `pflegepilot_v3`.
**Status (2026-07-29):** Alle Punkte aus dem ursprünglichen Tester-Feedback sind umgesetzt und deployed, zuletzt **T15** (Benachrichtigung bei Umkreis-Änderung) sowie ein zusätzlicher Punkt (24h-Pflegedienste, siehe T13b) aus späterem Tester-Feedback. **Kein offener Punkt mehr aus diesem Backlog.**
## Legende
| Feld | Bedeutung |
|---|---|
| Aufwand | niedrig / mittel / mittel-hoch / hoch — grobe Einschätzung, kein Story-Point-Wert |
| Abhängigkeit | andere Punkte in dieser Liste, die vorher (oder im selben Zug) erledigt sein sollten |
| Quelle | Zeilennummer im Original `Feedbackfromtester.txt` |
## Schnellübersicht (rein nach Aufwand sortiert)
1. ✅ **niedrig** — [T9 Versichertennummer-Format-Validierung](#t9-versichertennummer-format-validierung-niedrig)
2. ✅ **niedrig** — [T4 Auto-Fill E-Mail des Users](#t4-auto-fill-e-mail-des-users-niedrig)
3. ✅ **mittel** — [T1 Checkliste vor Antragstellung](#t1-checkliste-vor-antragstellung-mittel)
4. ✅ **mittel** — [T2 Interview-Erweiterung: Leistungsauswahl](#t2-interview-erweiterung-leistungsauswahl-mittel)
5. ✅ **mittel** — [T6 Vollmacht 2: Unterschriftsfelder wechseln](#t6-vollmacht-2-unterschriftsfelder-wechseln-mittel-abh-t5)
6. ✅ **mittel-hoch** — [T5 Vollmacht-Generator](#t5-vollmacht-generator-mittel-hoch)
7. ✅ **mittel-hoch** — [T3 Eigene Rolle + Steuervorteil](#t3-eigene-rolle--steuervorteil-mittel-hoch-abh-t2)
8. ✅ **niedrig-mittel** — [T18 Krisen-Checklisten/Guides](#t18-krisen-checklistenguides-niedrig-mittel-technisch-scope-noch-offen)
9. ✅ **niedrig-mittel** — [T12 Umkreissuche erweitert um Wohnformen](#t12-umkreissuche-erweitert-um-wohnformen-niedrig-mittel-abh-t10)
10. ✅ **niedrig-mittel** — [T13 Umkreissuche Therapie/Reha](#t13-umkreissuche-therapiereha-niedrig-mittel-abh-t10)
11. ✅ **mittel-hoch** — [T17 Budget-Tracker Entlastungsbetrag](#t17-budget-tracker-entlastungsbetrag-mittel-hoch-abh-t2t3)
12. ✅ **hoch** — [T8 OCR-Kartenerfassung](#t8-ocr-kartenerfassung-hoch)
13. ✅ **hoch** — [T10 Umkreissuche Pflegedienste](#t10-umkreissuche-pflegedienste-hoch)
14. ✅ **hoch** — [T11 Umkreissuche Sanitätshäuser](#t11-umkreissuche-sanitätshäuser-hoch-abh-t10)
15. ✅ **mittel-hoch** — [T14 Benachrichtigung bei Leistungskatalog-Änderung](#t14-benachrichtigung-bei-leistungskatalog-änderung-mittel-hoch)
16. ✅ **hoch** — [T16 E-Mail-Push-Einstellungen + Tagebuch-Check-in](#t16-e-mail-push-einstellungen--tagebuch-check-in-hoch)
17. ✅ **hoch** — [T15 Benachrichtigung bei Umkreis-Änderung](#t15-benachrichtigung-bei-umkreis-änderung-hoch-abh-t10t13-t16)
---
## Gruppe A: Bevollmächtigung / Vollmacht
Zusammenhängender Themenblock: der eingeloggte User (Bevollmächtigter) handelt im Namen des Patienten. Reihenfolge ist eine sinnvolle Umsetzungskette — T4 liefert die Grundannahme, T5 baut den eigentlichen Generator, T6 ist reines Folgeverhalten von T5.
### T4. Auto-Fill E-Mail des Users (niedrig) ✅
- **Original (Zeile 4):** "Auto Fill der App User Daten; der Patient hat meistens keine eigene E-Mail Adresse, hier müsste die E-Mail vom User verwendet werden da dieser bevollmächtigt ist."
- **Interpretation:** Wo aktuell die E-Mail des Patienten für Anschreiben/Kontaktzwecke erwartet wird, als Fallback die E-Mail des eingeloggten Users (`nutzer.email`) verwenden, sofern der Patient keine eigene hinterlegt hat.
- **Abhängigkeit:** keine harte, aber inhaltlich Grundlage für T5/T6.
### T5. Vollmacht-Generator (mittel-hoch) ✅
- **Original (Zeile 5):** "Vollmachten; Vollmacht Generator erstellen für die Krankenkassen, die Vollmacht muss vom Patienten und vom User unterschrieben werden, Daten aus dem System verwenden."
- **Interpretation:** Neuer Dokumenttyp analog zu Musterbrief/Widerspruch/Ummeldung (bestehendes Muster: `briefe_text.py` + serverseitiges PDF via WeasyPrint + autorisierter Download). Unterschied: zwei Unterschriftsfelder (Patient + Bevollmächtigter) statt eines.
- **Abhängigkeit:** keine zwingende, nutzt aber ggf. T4 (Kontaktdaten).
### T6. Vollmacht 2: Unterschriftsfelder wechseln (mittel, Abh. T5) ✅
- **Original (Zeile 6):** "sobald Vollmacht hinterlegt ist, werden die Unterschriften Felder vom Patienten zum Bevollmächtigten."
- **Interpretation:** Sobald eine hinterlegte Vollmacht für einen Patienten existiert, wechseln nachfolgende generierte Dokumente von "Unterschrift Patient" auf "Unterschrift Bevollmächtigter" (der User unterschreibt stellvertretend).
- **Abhängigkeit:** **T5 muss zuerst existieren** — ohne Vollmacht-Datensatz gibt es nichts, dessen Vorhandensein geprüft werden könnte.
---
## Gruppe B: Interview & Leistungsauswahl
Zusammenhängender Themenblock: Erweiterung des bestehenden 6-Schritte-Interview-Wizards um eine Leistungsauswahl aus dem Leistungskatalog plus Rollenverteilung. T3 baut inhaltlich auf T2 auf.
### T2. Interview-Erweiterung: Leistungsauswahl (mittel) ✅
- **Original (Zeile 2):** "Customer Journey; Ein Interview für die möglichen auswählbaren Bezüge. Siehe Leistungskatalog."
- **Interpretation:** Im Interview-Wizard einen Schritt ergänzen, der die laut Leistungskatalog für den Patienten in Frage kommenden Leistungen anzeigt und auswählen lässt.
- **Abhängigkeit:** keine.
### T3. Eigene Rolle + Steuervorteil (mittel-hoch, Abh. T2) ✅
- **Original (Zeile 3):** "eigene Rolle vergeben (was kann ich leisten, was will ich leisten); Primärer User soll angeben, welche Leistungen er selbstständig übernehmen kann/möchte aus dem Leistungskatalog, übrige aus dem Interview gewonnenen Leistungen müssten im Anschluss separat beantragt werden; schreibe den Steuervorteil der Eigenleistung hinzu."
- **Interpretation:** Auf der in T2 ausgewählten Leistungsliste zusätzlich markieren, welche Leistungen der User selbst erbringt vs. welche extern beantragt werden müssen, plus Steuervorteil-Hinweis (Pflegepauschbetrag) bei Eigenleistung.
- **Abhängigkeit:** **T2 muss zuerst existieren.**
---
## Gruppe C: Antragsprüfung
Eigenständig, keine Abhängigkeit zu den anderen Gruppen.
### T1. Checkliste vor Antragstellung (mittel) ✅
- **Original (Zeile 1):** "Checkliste; beim Beantragen einmal prüfen ob alles Notwendige für den Antrag schon hochgeladen worden ist, damit dies im Falle der Prüfung direkt bereitliegt."
- **Interpretation:** Vor dem Absenden/Generieren eines Antrags eine Prüfung einbauen, ob die für diesen Antragstyp nötigen Dokumente bereits hochgeladen sind.
---
## Gruppe D: Datenerfassung & Validierung Krankenversicherung
Beide Punkte betreffen die Krankenversicherungsdaten-Erfassung, liegen aber weit auseinander im Aufwand — keine Abhängigkeit zueinander.
### T9. Versichertennummer-Format-Validierung (niedrig) ✅
- **Original (Zeile 9):** "korrekte Anzahl der Krankenversicherungs-Nummer Format und Längen beachten (wenn falsch: Fehler!)."
- **Interpretation:** Reine Format-/Längenvalidierung der Versichertennummer (DE/AT/CH) beim Erfassen — schneller Gewinn.
### T8. OCR-Kartenerfassung (hoch) ✅
- **Original (Zeile 8):** "Abfotografieren der Krankenkassen Karte zur Datenübernahme (OCR müsste hier hinterlegt werden, sowie eine Python zur Auslesen der Bilddaten)."
- **Interpretation:** Foto der Krankenkassenkarte aufnehmen/hochladen lassen, per OCR Versichertennummer/Kassenname/Name auslesen und Formularfelder automatisch befüllen. Größter Einzelposten der Liste: neue Bildverarbeitungs-Pipeline, Fehlerbehandlung bei Fehlerkennung, Testaufwand über verschiedene Kartenlayouts/-anbieter hinweg.
- **Umgesetzt:** Tesseract OCR (offline, Betreiber-Entscheidung analog OSM statt Google Places), bewusst nur GKV/eGK (standardisiertes Layout, PKV ausgeschlossen). Alle fünf Felder (Vorname, Nachname, Geburtsdatum, Versichertennummer, Krankenkasse) werden übernommen, Scan-Button sitzt in Schritt 1 des "Neuer Patient"-Assistenten und zusätzlich im Bearbeiten-Formular. Details, inkl. eines beim Verifikationstest gefundenen und behobenen Fehltreffer-Bugs bei der Krankenkassen-Fuzzy-Erkennung, siehe CLAUDE.md-Abschnitt "eGK-Scan / OCR-Kartenerfassung". **Noch ohne echte Testfotos verifiziert** (nur synthetisches Testbild) — wie beim Dokumenten-Scan ist eine Nachbesserung anhand echter Nutzung wahrscheinlich nötig.
- **Abhängigkeit:** profitiert von T9, ist aber nicht davon blockiert.
---
## Gruppe E: Umkreissuche (Pflegedienste, Sanitätshäuser, Wohnformen, Therapie)
Vier Punkte, alle auf derselben Grundmechanik aufbauend: PLZ eingeben → Umkreis → Ergebnisliste mit Kontaktdaten. T10 legt die Infrastruktur (externe Datenquelle, Geokoordinaten/Distanzberechnung, Ergebnisliste), T11/T12/T13 sind im Kern nur weitere Kategorie-Filter auf derselben Infrastruktur — deshalb nach T10 deutlich billiger als T10 selbst.
### T10. Umkreissuche Pflegedienste (hoch) ✅
- **Original (Zeile 14):** "Umkreis Suche für Pflegedienste - Research aller Pflegedienste in DE, Umfeldsuche nach PLZ. Ausgabe Firmenname, Webseite, Telefon / E-Mail / WhatsApp"
- **Interpretation:** Anders als bei Krankenkassen (94 Einträge) oder Rentenversicherung (16 Einträge) gibt es in Deutschland mehrere Tausend Pflegedienste — eine handkuratierte, statische Liste ist hier nicht praktikabel. Braucht eine **Produktentscheidung zur Datenquelle** (z. B. Google Places API — kostenpflichtig pro Anfrage, oder OpenStreetMap/Overpass — kostenlos, aber lückenhafter) plus PLZ→Geokoordinaten-Auflösung und Umkreisberechnung. WhatsApp-Nummern sind über keine dieser Quellen zuverlässig abfragbar.
- **Umgesetzt:** OpenStreetMap (Betreiber-Entscheidung, siehe CLAUDE.md-Abschnitt "Umkreissuche für lokale Pflege-Dienstleister") statt Google Places — Nominatim (Geocoding) + Overpass API (Umkreissuche). WhatsApp wie erwartet über keine der beiden Quellen verfügbar, daher nicht ausgegeben.
- **Abhängigkeit:** keine, liefert aber die Infrastruktur für T11–T13.
### T11. Umkreissuche Sanitätshäuser (hoch, Abh. T10) ✅
- **Original (Zeile 15):** "Umkreis Suche für Sanitätshäuser - Research aller Sanitätshäuser in DE, Umfeldsuche nach PLZ. Ausgabe Firmenname, Webseite, Telefon / E-Mail / WhatsApp"
- **Interpretation:** Strukturell identisch zu T10, nur Kategorie "Sanitätshaus" statt "Pflegedienst".
- **Umgesetzt:** wie erwartet nur ein zusätzlicher `KATEGORIEN`-Eintrag (`shop=medical_supply`), kein Aufwand an Such-/Router-/Frontend-Logik.
- **Abhängigkeit:** **Sollte nach T10 kommen** — nutzt dieselbe Umkreissuche-Anbindung, nur anderer Kategorie-Filter.
### T12. Umkreissuche erweitert um Wohnformen (niedrig-mittel, Abh. T10) ✅
- **Original (Zeile 16):** "Umkreis Suche ergänzend mit Pflegeheime / Seniorenheime / Tagespflege / Demenzgruppen / betreutes Wohnen / Sozial Cafés / Altersheime mittels Filter zuschaltbar."
- **Interpretation:** Weitere zuschaltbare Kategorie-Filter (6-7 zusätzliche Einrichtungstypen) auf derselben T10-Infrastruktur. Der einzige Mehraufwand ist die richtige Kategorie-Zuordnung in der gewählten Datenquelle (nicht jede Kategorie ist bei Google Places/OSM 1:1 als eigener Typ hinterlegt, z. B. "Demenzgruppe" oder "Soziales Café" sind keine Standard-Kategorien — ggf. über Freitextsuche/Namensmuster statt Kategorie-Filter lösen).
- **Umgesetzt:** Pflegeheim, Betreutes Wohnen/Seniorenheim, Tagespflege, Demenz-Wohngruppe als vollwertige Kategorien (inkl. Mitabfrage älterer, laut OSM-Wiki noch nicht vollständig migrierter Tags). "Soziales Café" wie vermutet ohne verlässliches Tag — Behelfslösung über `amenity=community_centre`, Label markiert das transparent als "(geringe Datenlage)".
- **Abhängigkeit:** **braucht T10.**
### T13b. Umkreissuche 24h-Pflegedienste (niedrig, Abh. T10, ergänzt 2026-07-29) ✅
- **Herkunft:** zusätzliches Tester-Feedback nach T8-Abschluss, nicht Teil der ursprünglichen `Feedbackfromtester.txt`.
- **Umgesetzt:** neue Kategorie `pflegedienst_24h` in derselben Registry. Kein eigenes OSM-Tag für "24-Stunden-Pflege" vorhanden — Namens-Musterfilter (`"24h"`/`"24-Stunden"` im Firmennamen) auf denselben `ambulatory_care`-Treffern statt eines Tag-Filters, Label transparent mit "(geringe Datenlage)" markiert (gleiche Kompromiss-Logik wie "Soziales Café"). Details siehe CLAUDE.md.
### T13. Umkreissuche Therapie/Reha (niedrig-mittel, Abh. T10) ✅
- **Original (Zeile 17):** "Umkreis Suche Logopädie, Physiotherapie / Physiozentrum, Reha-Sport, Reha-Zentrum"
- **Interpretation:** Wie T12, weitere Kategorie-Filter (Therapie-/Reha-Einrichtungen) auf der T10-Infrastruktur.
- **Umgesetzt:** Logopädie, Physiotherapie, Reha-Zentrum als Kategorien. "Reha-Sport" bewusst **ausgelassen** — es existiert kein brauchbares OSM-Tag dafür (Kurse laufen meist über normale Sportvereine/Fitnessstudios ohne eigenen kartierbaren Ort); eine Kategorie mit strukturell unzuverlässiger Trefferquote anzubieten wäre schlechter als sie wegzulassen.
- **Abhängigkeit:** **braucht T10.**
---
## Gruppe F: Benachrichtigungen (Katalog- & Umkreis-Änderungen, Push-Reminder)
**Wichtiger Vorbehalt, bevor hier irgendetwas gebaut wird:** Alle drei Punkte brauchen serverseitig **proaktiv verschickte** E-Mails (nicht nutzerausgelöst). Aktuell hat `pflegepilot_v3` genau eine bewusste Ausnahme von "kein proaktiver Server-Mailversand" — das Passwort-Reset — mit der expliziten Notiz, das nicht als Freibrief für weiteren proaktiven Mailversand zu lesen (siehe CLAUDE.md, Abschnitt "Passwort-vergessen per SMTP"). Diese drei Punkte würden diese Architekturentscheidung bewusst aufweichen — das ist eine Produktentscheidung, keine reine Implementierungsdetail-Frage, und sollte vor Umsetzungsbeginn explizit noch einmal bestätigt werden.
### T14. Benachrichtigung bei Leistungskatalog-Änderung (mittel-hoch) ✅
- **Original (Zeile 20):** "Benachrichtigungs-Funktion via E-Mail an den Nutzer parallel an den Patienten falls sich der Leitungskatalog aktualisiert oder verbessern oder verschlechtert."
- **Interpretation:** Wenn sich ein Katalogeintrag ändert (Betrag, Voraussetzung, Wegfall), sollen Nutzer, deren Patient diese Leistung zugewiesen hat, per E-Mail informiert werden. Der Leistungskatalog hat aktuell **keine Versionierung** — ähnlich wie beim bestehenden Changelog-System (versionierte Einträge + "gesehen"-Tracking pro Nutzer) müsste hier erst eine Änderungs-Historie pro Katalogeintrag eingeführt werden, bevor überhaupt erkennbar ist, *dass* sich etwas geändert hat.
- **Abhängigkeit:** braucht eine grundsätzliche E-Mail-Versand-Freigabe (siehe Vorbehalt oben).
### T16. E-Mail-Push-Einstellungen + Tagebuch-Check-in (hoch) ✅
- **Original (Zeile 23):** "E-Mail Push Service einstellbar in den Nutzereinstellungen bezüglich ein und ausschalten und der Uhrzeit; 'Automatisierte Erinnerungen (Push-Notifications): „Wie war der Tag mit Oma heute?" – Kurze 30-Sekunden-Check-ins (Schlaf, Stimmung, Vorfälle, Medikation).' -> Damit das Tagebuch gefüllt wird."
- **Interpretation:** Nutzer-Einstellung (an/aus + Uhrzeit) für eine tägliche Erinnerungs-E-Mail, die zum Ausfüllen des Pflegetagebuchs anregt. Braucht einen **echten Zeit-Scheduler** — die App hat bewusst keinen (siehe CLAUDE.md: Demo-Reset läuft absichtlich über einen "lazy check" bei Request statt über Cron, weil es "keine Task-Scheduler-Architektur gibt und das auch so bleiben soll"). Ein pro-Nutzer-Uhrzeit-getriggerter täglicher Mailversand ist ohne einen solchen Scheduler nicht sauber umsetzbar — das ist der eigentliche Grund für die hohe Aufwandseinschätzung, nicht die E-Mail an sich.
- **Abhängigkeit:** braucht eine grundsätzliche E-Mail-Versand-Freigabe (siehe Vorbehalt oben) + eine neue Scheduler-Infrastruktur.
### T15. Benachrichtigung bei Umkreis-Änderung (hoch, Abh. T10–T13, T16) ✅
- **Original (Zeile 21):** "Benachrichtung im Umkreis hat sich einer der Umkreis-Suchen-Dienste geändert."
- **Interpretation:** Wenn sich Kontaktdaten eines in der Umkreissuche gefundenen Dienstes ändern, soll der Nutzer benachrichtigt werden. Braucht einen wiederkehrenden Hintergrundjob, der gespeicherte/relevante externe Datensätze regelmäßig neu abfragt und mit dem vorherigen Stand vergleicht (Diffing).
- **Umgesetzt (2026-07-29):** `app/umkreis_favoriten_watch.py` — täglicher Hintergrundjob (Postgres-Advisory-Lock, gleiches Muster wie T16), fragt alle gemerkten Favoriten in Sammel-Anfragen per Overpass-ID-Filter ab (statt einer Anfrage je Favorit — Rücksicht auf das knappe Rate-Limit des öffentlichen Mirrors, real erlebt: nur 2 gleichzeitige Slots pro IP), vergleicht Name/Adresse/Telefon/E-Mail/Webseite und verschickt bei Abweichung eine gebündelte E-Mail pro Konto. End-to-end gegen einen echten gemerkten Favoriten verifiziert. Details siehe CLAUDE.md.
- **Bewusste Einschränkung:** ein komplett aus OSM verschwundener Favorit wird nur geloggt, nicht per Mail gemeldet (bräuchte eine Dedupe-Schutzmaßnahme gegen tägliche Wiederholungsmeldungen, die für den ursprünglichen Wunsch überkonstruiert wäre).
---
## Gruppe G: Engagement & Alltagsnutzen
Zwei eigenständige Punkte, beide vom Tester mit derselben Motivation versehen: Gründe schaffen, die App regelmäßig zu öffnen.
### T17. Budget-Tracker Entlastungsbetrag (mittel-hoch, Abh. T2/T3) ✅
- **Original (Zeile 24):** "Budget-Tracker: Eine visuelle Übersicht: „Du hast diesen Monat noch 125 € Entlastungsbetrag übrig. Wenn du ihn bis zum 30.06. nicht nutzt, verfällt er!" [...] Vorraussetzung hierfür ist das die Beträge vorher für den Patienten eingepflegt worden sind, im nachgang kann man pro Patient die Ausgaben erfassen, um dann täglich den Restbetrag genauer zu sehen."
- **Interpretation:** Baut direkt auf der in T2/T3 gebauten Leistungen-Verwaltung auf (der Entlastungsbetrag ist bereits ein Katalog-Eintrag, den ein Patient zugewiesen bekommen kann). Braucht zusätzlich: ein Budget/Zeitraum pro Leistung, eine neue "Ausgabe"-Erfassung (Betrag, Datum, Beschreibung) pro Leistung, Restbetrag-Berechnung und einen Verfallsdatum-Hinweis (Entlastungsbetrag ist laut Katalog bis 30.6. des Folgejahres übertragbar).
- **Abhängigkeit:** **braucht T2/T3** (Leistungen-Verwaltung muss existieren, bevor man ihr ein Budget/Ausgaben hinzufügen kann).
### T18. Krisen-Checklisten/Guides (niedrig-mittel technisch, Scope noch offen) ✅
- **Original (Zeile 25):** "Checklisten für Krisenmomente: Schnell abrufbare Guides, wenn es brennt (z. B. „Plötzliche Hospitalisierung – was ist am ersten Tag zu tun?"). -> Ist nur ein Beispiel, aber die Rubrik -Guides finde ich gut, sollten wir ausarbeiten."
- **Interpretation:** Technisch günstig (im Kern eine neue, einfache Content-Seite mit einer Liste abrufbarer Anleitungstexte, ähnlich simpel wie die bestehende Rechtstext-Anzeige) — der eigentliche Aufwand liegt im **Inhalt**, nicht im Code: welche Krisensituationen genau, wie viele Guides, wie detailliert. Der Tester selbst sagt, das Beispiel sei nur exemplarisch und die Liste müsse noch gemeinsam erarbeitet werden.
- **Abhängigkeit:** keine technische, aber **braucht zuerst ein Scoping-Gespräch** (welche Guides genau), bevor sinnvoll geschätzt oder gebaut werden kann.
---
## Empfohlene Bearbeitungsreihenfolge (Gruppen + Aufwand kombiniert)
1. ✅ T9 → T4 (niedrig, unabhängig, schnelle Gewinne)
2. ✅ T1 (mittel, unabhängig)
3. ✅ T2 → T3 (mittel → mittel-hoch, Kette)
4. ✅ T5 → T6 (mittel-hoch → mittel, Kette)
5. ✅ T18 (Krisen-Checklisten, Guide-Liste mit Betreiber abgestimmt)
6. ✅ T17 (Budget-Tracker, baut auf T2/T3 auf)
7. ✅ T14 (Katalog-Änderungsbenachrichtigung, Katalog-Snapshot-Vergleich)
8. ✅ T16 (Reminder-Scheduler, E-Mail-Freigabe erweitert)
9. ✅ T10 → T11 → T12 → T13 (Umkreissuche-Familie, OSM/Nominatim/Overpass, alle 10 Kategorien live)
10. ✅ T8 (OCR-Kartenerfassung, Tesseract offline, nur GKV/eGK)
11. ✅ T15 (Benachrichtigung bei Umkreis-Änderung, täglicher Hintergrundjob, Sammel-Abfrage per Overpass-ID-Filter)
12. ✅ T13b (24h-Pflegedienste, zusätzliches Feedback vom 2026-07-29)
**Damit sind alle Punkte aus dem Backlog umgesetzt.**
+33
View File
@@ -0,0 +1,33 @@
- Checkliste; beim beantragen einmal prüfen ob alles notwendige für den antrag schon hochgeladen worden ist, damit dies im falle der Prüfung direkt bereitliegt.
- ⁠Customer Journey; Ein Interview für die Möglichen auswählbaren, Bezüge. Siehe Leistungskatalog
- ⁠eigene Rolle vergeben ( was kann ich leisten , was will ich leisten ); Primärer User soll angeben, welche Leistungen er selbstständig übernehmen kann /möchte aus dem Leistungskatalog, übrige aus dem Interview gewonnen Leistungen müssten im anschluss separat beantrag werden; schreibe den Steuervorteil der Eigenleistung hinzu.
- ⁠Auto Fill der App User Daten; der Patient hat meistens keine eigene E-Mail Adresse, hier müsste die E-Mail vom User verwendet werden da dieser bevollmächtigt ist.
- ⁠Vollmachten; Vollmacht Generator erstellen für die Krankenkassen, die vollmacht muss vom Patienten und vom User unterschrieben werden, Daten aus dem System verwenden.
- Vollmacht 2; sobald vollmacht hinterlegt ist, werden die Unterschriften Felder vom Patienten zum Bevollmächtigten.
- abfotografieren der Krankenkassen Karte zur Datenübernahme (OCR müsste Hier hinterlegt werden, sowie eine Python zur auslesen der Bilddaten)
- ⁠korrekte Anzahl der Krankenversicherungs-Nummer Format und Längen Beachten( wenn falsch: Fehler!)
- Umkreis Suche für Pflegedienste - Research aller Pflegedienste in DE, Umfeldsuche nach PLZ. Ausgabe Firmenname, Webseite, Telefon / E-Mail / WhatsApp
- Umkreis Suche für Sanitätshäuser - Research aller Sanitätshäuser in DE, Umfeldsuche nach PLZ. Ausgabe Firmenname, Webseite, Telefon / E-Mail / WhatsApp
- Umkreis Suche ergänzend mit Pflegeheime / Seniorenheime / Tagespflege / Demenzgruppen / betreutes Wohnen / Sozial Cafés / Altersheime mittels Filter zuschaltbar.
- Umkreis Suche Logopädie, Physiotherapie / Pyhsiozentrum, Reha-Sport, Reha-Zentrum
- Benachrichtungs-Funktion via E-Mail an den Nutzer parallel an den Patienten falls sich der Leitungskatalog aktualisiert oder verbessern oder verschlechtert.
- Benachrichtung im Umkreis hat sich einer der Umkreis-Suchen-Dienste geändert.
- E-Mail Push Service einstellbar in den Nutzereinstellungen bezüglich ein und ausschalten und der Uhrzeit; "Automatisierte Erinnerungen (Push-Notifications): „Wie war der Tag mit Oma heute?“ – Kurze 30-Sekunden-Check-ins (Schlaf, Stimmung, Vorfälle, Medikation)." -> Damit das Tagebuch gefüllt wird.
- Budget-Tracker: Eine visuelle Übersicht: „Du hast diesen Monat noch 125 € Entlastungsbetrag übrig. Wenn du ihn bis zum 30.06. nicht nutzt, verfällt er!“ Das schafft dringende Gründe, die App monatlich zu öffnen. - Vorraussetzung hierfür ist das die Beträge vorher für den Patienten eingepflegt worden sind, im nachgang kann man pro Patient die ausgaben erfassen, um dann täglich den Restbetrag genauer zu sehen.
- Checklisten für Krisenmomente: Schnell abrufbare Guides, wenn es brennt (z.B. „Plötzliche Hospitalisierung – was ist am ersten Tag zu tun?“). ->Ist nur ein Beispiel, aber die Rubrik -Guides finde ich gut, sollten wir ausarbeiten.
-Hilfsmittelbeantragung nach Zeit - z.b. Kompressionsstrümpfe - nach Beantragung korrekte Wiederholung unter Aufgaben ablegen als Reminder für den nächsten Antrag.
-Kalender Benachrichtungen via E-Mail 1 Tag vor Fälligkeit und 7 Tage vor Fälligkeit (softreminder)
-Kalender Überfälligkeiten in der Sidebare Visuell-Darstellen.
- Adresse Autofill nach der PLZ eingabe soll der Ort automatisch gefunden werden, ggf. datenbank dazu anlegen.
- bau im Patienten Interview bei der Adressabfrage ein Kästchen ein, Adresse gleich wie "Nutzer" - wenn angeklickt Nutzer Adresse automatisch einfügen.
- Browser Safari hat jetzt ein paar mal probleme erzeugt beim erstellen von neuen Patienten, hier ist das Problem, Nutzer füllt Step 1 aus geht weiter auf Step 2 und wir dann auf Step 1 zurückgeführt statt auf Step 3 weiter geleitet zu werden. Ich konnte das Problem nicht validieren, da kein Safari und iOS vorort. In GoogleChrome und Firefox konnt ich es nicht reproduzieren.
+10 -2
View File
@@ -1,6 +1,11 @@
FROM python:3.12-slim
# WeasyPrint braucht diese nativen Libs zum PDF-Rendern (Pango/Cairo/GDK-Pixbuf)
# WeasyPrint braucht diese nativen Libs zum PDF-Rendern (Pango/Cairo/GDK-Pixbuf).
# postgresql-client liefert pg_dump fuers System-Backup (app/backup.py) - der
# Python-Postgres-Treiber (psycopg) kann selbst keinen Dump erzeugen.
# tesseract-ocr (+ -deu Sprachpaket) fuer die eGK-Kartenerfassung
# (app/egk_ocr.py) - bewusst offline/kostenlos statt einer Cloud-Vision-API,
# siehe CLAUDE.md.
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
@@ -9,6 +14,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libffi8 \
shared-mime-info \
fonts-dejavu-core \
postgresql-client \
tesseract-ocr \
tesseract-ocr-deu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -19,7 +27,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser \
&& mkdir -p /app/app/uploads \
&& mkdir -p /app/app/uploads /app/app/backups \
&& chown -R appuser:appuser /app
USER appuser
+238
View File
@@ -0,0 +1,238 @@
"""1:1-Sicherungs-Backup fuer Datenbank + hochgeladene Dateien (Betreiber-
Wunsch: reine Absicherung gegen NAS-/Datenverlust - siehe Sicherheits-
gespraech CLAUDE.md "Backup-Funktion"). Bewusst kein App-interner
Datenexport wie der DSGVO-Export (der ist auf einen einzelnen Patienten
beschraenkt und fuer Betroffenenrechte gedacht) - dies ist ein System-Backup
fuer den Betreiber ueber alle Konten/Patienten hinweg.
Ein Backup ist ein einzelnes .tar.gz-Archiv mit zwei Teilen:
- database.sql: kompletter Postgres-Dump (pg_dump, Klartext-SQL statt
Custom-Format - ein Restore braucht dadurch keine zur Dump-Version
passende pg_restore-Version, universeller wiederherstellbar).
- uploads/: 1:1-Kopie des kompletten Uploads-Verzeichnisses (gescannte
Dokumente, Briefe/PDFs, Fotos aller Konten).
"""
import datetime
import logging
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
from app.config import settings
from app.db import engine
logger = logging.getLogger(__name__)
# Nur Dateien, die exakt diesem Muster entsprechen, werden als Backup
# erkannt/geloescht/heruntergeladen - Schutz vor Path Traversal ueber einen
# vom Client frei waehlbaren Dateinamen im Download-/Loesch-Endpoint.
BACKUP_DATEINAME_MUSTER = re.compile(r"^backup_\d{8}_\d{6}\.tar\.gz$")
BACKUPS_BEHALTEN = 5
class BackupFehler(Exception):
"""pg_dump ist fehlgeschlagen o.ae. - fuer den Router, um daraus ein
sauberes 500 mit lesbarer Fehlermeldung zu machen."""
def _pg_dump_url() -> str:
"""pg_dump versteht SQLAlchemy-Treiber-Suffixe wie '+psycopg' in der
Connection-URL nicht - auf das von pg_dump erwartete reine
'postgresql://...'-Schema reduzieren."""
return re.sub(r"^postgresql\+\w+://", "postgresql://", settings.database_url)
def erstelle_backup() -> str:
os.makedirs(settings.backups_dir, exist_ok=True)
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
dateiname = f"backup_{zeitstempel}.tar.gz"
ziel_pfad = os.path.join(settings.backups_dir, dateiname)
dump_pfad = os.path.join(settings.backups_dir, f"_tmp_{zeitstempel}.sql")
ergebnis = subprocess.run(
[
"pg_dump",
"--no-owner",
"--no-privileges",
# --clean --if-exists: der Dump enthaelt DROP-Anweisungen vor
# jedem CREATE, damit er sich beim Restore auch gegen eine
# bereits (per Alembic-Migration) angelegte, aber leere
# Datenbank sauber abspielen laesst, statt mit "relation already
# exists" abzubrechen - genau der Zustand, in dem sich eine
# frisch deployte Instanz nach einem Totalausfall befindet.
"--clean",
"--if-exists",
"-f", dump_pfad,
_pg_dump_url(),
],
capture_output=True,
text=True,
timeout=600,
)
if ergebnis.returncode != 0:
if os.path.exists(dump_pfad):
os.remove(dump_pfad)
raise BackupFehler(f"pg_dump fehlgeschlagen: {ergebnis.stderr.strip()}")
try:
with tarfile.open(ziel_pfad, "w:gz") as tar:
tar.add(dump_pfad, arcname="database.sql")
if os.path.isdir(settings.uploads_dir):
tar.add(settings.uploads_dir, arcname="uploads")
finally:
os.remove(dump_pfad)
logger.info("Backup erstellt: %s", dateiname)
return dateiname
def liste_backups() -> list[dict]:
if not os.path.isdir(settings.backups_dir):
return []
eintraege = []
for fn in os.listdir(settings.backups_dir):
if not BACKUP_DATEINAME_MUSTER.match(fn):
continue
full = os.path.join(settings.backups_dir, fn)
stat = os.stat(full)
eintraege.append(
{
"dateiname": fn,
"groesse_bytes": stat.st_size,
"erstellt_am": datetime.datetime.fromtimestamp(
stat.st_mtime, tz=datetime.timezone.utc
),
}
)
eintraege.sort(key=lambda e: e["erstellt_am"], reverse=True)
return eintraege
def alte_backups_aufraeumen(behalten: int = BACKUPS_BEHALTEN) -> None:
for alt in liste_backups()[behalten:]:
pfad = os.path.join(settings.backups_dir, alt["dateiname"])
if os.path.exists(pfad):
os.remove(pfad)
logger.info("Altes Backup geloescht: %s", alt["dateiname"])
def backup_pfad(dateiname: str) -> str | None:
if not BACKUP_DATEINAME_MUSTER.match(dateiname):
return None
pfad = os.path.join(settings.backups_dir, dateiname)
return pfad if os.path.isfile(pfad) else None
# Konfigurationsparameter, die neuere pg_dump-Versionen in den Dump
# schreiben, aber die auf dem Server dieser App (Postgres 16) noch nicht
# existieren - das Docker-Image installiert das generische
# "postgresql-client"-Paket der Debian-Distribution, das eine neuere
# Client-Version (17) als der Server (16) mitbringt. Ohne diese Bereinigung
# wuerde der SET-Befehl den Restore mit ON_ERROR_STOP/--single-transaction
# komplett abbrechen lassen (real getestet, kein theoretisches Risiko).
_INKOMPATIBLE_SET_MUSTER = re.compile(r"^SET transaction_timeout\s*=.*;\s*$", re.MULTILINE)
def _dump_kompatibel_machen(dump_pfad: str) -> None:
with open(dump_pfad, encoding="utf-8") as f:
inhalt = f.read()
bereinigt = _INKOMPATIBLE_SET_MUSTER.sub(
"-- (entfernt: transaction_timeout existiert erst ab Postgres 17)", inhalt
)
if bereinigt != inhalt:
with open(dump_pfad, "w", encoding="utf-8") as f:
f.write(bereinigt)
def restore_backup(archiv_pfad: str) -> str:
"""Spielt ein zuvor mit erstelle_backup() erzeugtes Archiv vollstaendig
ein (Datenbank + Uploads) - fuer den Wiederaufbau nach einem
Totalausfall (Betreiber-Wunsch). Gibt den Dateinamen der vor dem Restore
automatisch angelegten Sicherheitskopie zurueck.
Reihenfolge bewusst so gewaehlt, dass nichts Destruktives passiert,
bevor die hochgeladene Datei nicht als minimal gueltig bestaetigt ist:
1. Archiv in ein Temp-Verzeichnis entpacken, database.sql muss vorhanden
sein - sonst Abbruch, ohne dass am aktuellen Zustand etwas veraendert
wurde.
2. ERST DANACH eine Sicherheitskopie des aktuellen Zustands anlegen
(Betreiber-Entscheidung: schuetzt vor einem Restore mit versehentlich
falscher/veralteter Datei).
3. Datenbank per psql einspielen (--single-transaction + ON_ERROR_STOP:
entweder komplett oder gar nicht, kein halb kaputter Zwischenzustand
bei einem Fehler mittendrin).
4. "alembic upgrade head" erneut ausfuehren - falls seit dem Stand des
Backups neue Migrationen zum aktuell laufenden Code hinzukamen, holt
das die wiederhergestellte Datenbank automatisch auf den aktuellen
Schema-Stand.
5. Uploads-Verzeichnis 1:1 durch den Inhalt aus dem Archiv ersetzen.
6. engine.dispose(): saemtliche gepoolten Datenbankverbindungen
verwerfen, damit nachfolgende Requests garantiert frische
Verbindungen gegen die wiederhergestellte Datenbank aufbauen statt
potenziell noch "alte" gepoolte Zustaende wiederzuverwenden.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
with tarfile.open(archiv_pfad, "r:gz") as tar:
tar.extractall(tmp_dir, filter="data")
dump_pfad = os.path.join(tmp_dir, "database.sql")
if not os.path.isfile(dump_pfad):
raise BackupFehler("Archiv enthält keine database.sql - kein gültiges Backup")
_dump_kompatibel_machen(dump_pfad)
sicherheitskopie = erstelle_backup()
restore_ergebnis = subprocess.run(
[
"psql",
"--single-transaction",
"--set", "ON_ERROR_STOP=1",
"-f", dump_pfad,
_pg_dump_url(),
],
capture_output=True,
text=True,
timeout=900,
)
if restore_ergebnis.returncode != 0:
raise BackupFehler(
f"Datenbank-Restore fehlgeschlagen (Sicherheitskopie {sicherheitskopie} "
f"wurde vorher angelegt): {restore_ergebnis.stderr.strip()}"
)
migration_ergebnis = subprocess.run(
["alembic", "upgrade", "head"],
cwd="/app",
capture_output=True,
text=True,
timeout=120,
)
if migration_ergebnis.returncode != 0:
logger.error("alembic upgrade head nach Restore fehlgeschlagen: %s", migration_ergebnis.stderr.strip())
uploads_quelle = os.path.join(tmp_dir, "uploads")
if os.path.isdir(uploads_quelle):
# settings.uploads_dir ist ein Docker-Volume-Mountpunkt - der
# Mountpunkt selbst laesst sich nicht loeschen/neu anlegen
# (shutil.rmtree darauf schlaegt mit "Device or resource busy"
# fehl, real getestet). Deshalb nur den INHALT leeren statt das
# Verzeichnis selbst zu entfernen.
os.makedirs(settings.uploads_dir, exist_ok=True)
for eintrag in os.listdir(settings.uploads_dir):
pfad = os.path.join(settings.uploads_dir, eintrag)
if os.path.isdir(pfad) and not os.path.islink(pfad):
shutil.rmtree(pfad)
else:
os.remove(pfad)
for eintrag in os.listdir(uploads_quelle):
shutil.move(os.path.join(uploads_quelle, eintrag), os.path.join(settings.uploads_dir, eintrag))
engine.dispose()
logger.info("Restore abgeschlossen (Sicherheitskopie vorher: %s)", sicherheitskopie)
return sicherheitskopie
@@ -0,0 +1,67 @@
"""Taeglich wiederkehrendes automatisches Backup (Betreiber-Wunsch: reine
Absicherung, soll auch laufen, ohne dass jemand manuell daran denkt).
Gleiches Muster wie reminder_scheduler.py (siehe dort fuer die ausfuehrliche
Begruendung): einfacher Hintergrund-Thread mit Python-Bordmitteln statt
APScheduler/Celery, geschuetzt durch einen ueber die gesamte Thread-
Lebensdauer gehaltenen Postgres-Advisory-Lock, damit nicht jeder Gunicorn-
Worker sein eigenes taegliches Backup anstoesst.
Es gibt bewusst keine eigene "letztes Backup war am..."-Spalte irgendwo -
das waere ein weiteres Stueck Zustand, das synchron gehalten werden muesste.
Stattdessen wird direkt der Dateiname/Zeitstempel des juengsten vorhandenen
Backups (aus app/backup.py: liste_backups()) als Quelle der Wahrheit
verwendet: ist das juengste Backup nicht von heute, wird eines erstellt."""
import datetime
import logging
import threading
import time
from sqlalchemy import text
from app.backup import BackupFehler, alte_backups_aufraeumen, erstelle_backup, liste_backups
from app.db import engine
logger = logging.getLogger(__name__)
_ADVISORY_LOCK_ID = 412857930
_PRUEF_INTERVALL_SEKUNDEN = 3600 # stuendlich pruefen, ob das heutige Backup schon existiert
def starte_backup_scheduler() -> None:
thread = threading.Thread(target=_scheduler_thread, name="backup-scheduler", daemon=True)
thread.start()
def _scheduler_thread() -> None:
lock_connection = engine.connect()
got_lock = lock_connection.execute(
text("SELECT pg_try_advisory_lock(:id)"), {"id": _ADVISORY_LOCK_ID}
).scalar()
if not got_lock:
logger.info("Backup-Scheduler laeuft schon in einem anderen Worker - dieser Thread beendet sich.")
lock_connection.close()
return
logger.info("Backup-Scheduler gestartet (prueft alle %s Sekunden).", _PRUEF_INTERVALL_SEKUNDEN)
try:
while True:
try:
_taegliches_backup_falls_faellig()
except Exception:
logger.exception("Fehler im Backup-Scheduler-Durchlauf")
time.sleep(_PRUEF_INTERVALL_SEKUNDEN)
finally:
lock_connection.close()
def _taegliches_backup_falls_faellig() -> None:
heute = datetime.date.today()
vorhandene = liste_backups()
if vorhandene and vorhandene[0]["erstellt_am"].date() == heute:
return
try:
erstelle_backup()
alte_backups_aufraeumen()
except BackupFehler:
logger.exception("Automatisches taegliches Backup fehlgeschlagen")
+336 -26
View File
@@ -10,12 +10,33 @@ import datetime
from app.static_data.krankenkassen import get_by_name
from app.static_data import rentenversicherung
from app.static_data.widerspruch import MUSTERBRIEF_BETREFF, WGRUENDE
from app.static_data.patientenverfuegung_textbausteine import (
MASSNAHMEN,
ORGANSPENDE_KONFLIKT_OPTIONEN,
ORT_OPTIONEN,
SITUATIONEN,
VERTRAUENSPERSON_OPTIONEN,
WIEDERBELEBUNG_A_OPTIONEN,
WIEDERBELEBUNG_B_OPTIONEN,
)
def _de_datum(d: datetime.date | None) -> str:
return d.strftime("%d.%m.%Y") if d else "[Datum]"
def _unterschrift_zeilen(name: str, unterzeichner: str | None) -> list[str]:
"""Unterschriftszeile(n) am Briefende. Normalerweise nur der Name der
versicherten Person - sobald fuer den Patienten eine Vollmacht hinterlegt
ist (siehe routers/briefe.py: _hat_vollmacht()), unterschreibt stattdessen
der Bevollmaechtigte "in Vollmacht" (Tester-Feedback "Vollmacht 2": die
Unterschriftsfelder wechseln automatisch vom Patienten zum
Bevollmaechtigten, sobald eine Vollmacht existiert)."""
if unterzeichner and unterzeichner != name:
return [unterzeichner, f"in Vollmacht für {name}"]
return [name]
def _kk_adresse(name: str | None) -> str:
if not name:
return "[Anschrift der Krankenkasse bitte eintragen]"
@@ -44,21 +65,22 @@ def _rv_adresse(name: str | None) -> str:
def build_widerspruch_text(
*,
name: str,
adresse: str,
geburtsdatum: datetime.date | None,
versichertennummer: str | None,
krankenkasse: str | None,
bescheiddatum: datetime.date,
aktenzeichen: str | None,
ist_grad: int,
soll_grad: int,
grund_ids: list[str],
beweismittel: list[str],
unterzeichner: str | None = None,
) -> str:
"""Body-Fliesstext ab Datum/Betreff - Absender/Empfaenger stehen nicht
mehr hier, sondern werden separat als absender/empfaenger ans
DIN-5008-Fensterkuvert-Template uebergeben (siehe routers/briefe.py)."""
heute = _de_datum(datetime.date.today())
bd_f = _de_datum(bescheiddatum)
geb_f = _de_datum(geburtsdatum) if geburtsdatum else ""
kk_adr = _kk_adresse(krankenkasse)
ausgewaehlt = [g for g in WGRUENDE if g["id"] in grund_ids]
# Reihenfolge der Normen = erstes Auftreten unter den ausgewaehlten Gruenden,
@@ -72,8 +94,7 @@ def build_widerspruch_text(
ist_text_2 = "fehlende Pflegegrad" if ist_grad == 0 else f"Pflegegrad {ist_grad}"
zeilen = [
name, adresse, "", heute, "",
krankenkasse or "[Krankenkasse]", kk_adr, "",
f"Datum: {heute}", "",
"─" * 55,
f"Widerspruch gegen Bescheid vom {bd_f}",
]
@@ -119,7 +140,8 @@ def build_widerspruch_text(
"Der Widerspruch wird innerhalb der Frist des § 84 Abs. 1 SGG (1 Monat ab",
"Zustellung des Bescheids) fristgerecht eingelegt.",
"",
"Mit freundlichen Grüßen,", "", "", name,
"Mit freundlichen Grüßen,", "", "",
*_unterschrift_zeilen(name, unterzeichner),
]
return "\n".join(zeilen)
@@ -127,24 +149,31 @@ def build_widerspruch_text(
def build_musterbrief_text(
*,
absender_name: str,
absender_strasse: str,
absender_plz_ort: str,
absender_telefon: str,
krankenkasse: str | None,
absender_email: str | None = None,
typ: str,
versicherter_name: str,
geburtsdatum: datetime.date | None,
versichertennummer: str | None,
unterzeichner: str | None = None,
) -> str:
"""Body-Fliesstext ab Datum/Betreff - Absender/Empfaenger stehen nicht
mehr hier, sondern werden separat als absender/empfaenger ans
DIN-5008-Fensterkuvert-Template uebergeben (siehe routers/briefe.py)."""
heute = _de_datum(datetime.date.today())
kk_adr = _kk_adresse(krankenkasse)
betreff = MUSTERBRIEF_BETREFF.get(typ, MUSTERBRIEF_BETREFF["erstantrag"])
geb_f = _de_datum(geburtsdatum) if geburtsdatum else "[Geburtsdatum]"
kontaktzeilen = []
if absender_telefon:
kontaktzeilen.append(f"Tel.: {absender_telefon}")
if absender_email:
kontaktzeilen.append(f"E-Mail: {absender_email}")
zeilen = [
absender_name, absender_strasse, absender_plz_ort, f"Tel.: {absender_telefon}", "",
f"Datum: {heute}", "",
"An die:", krankenkasse or "[Krankenkasse]", kk_adr, "", "",
*kontaktzeilen,
*([""] if kontaktzeilen else []),
f"Betreff: {betreff}", "",
"Sehr geehrte Damen und Herren,", "",
"hiermit stelle ich einen Antrag für folgende versicherte Person:", "",
@@ -156,10 +185,10 @@ def build_musterbrief_text(
f"Dieser Antrag wird mit dem heutigen Datum ({heute}) gestellt,",
"damit etwaige Leistungen ab diesem Zeitpunkt berücksichtigt werden.", "",
"Mit freundlichen Grüßen,", "", "", "_____________________________",
absender_name, "",
*_unterschrift_zeilen(absender_name, unterzeichner), "",
"─" * 39,
"Hinweis: Per Einschreiben mit Rückschein versenden.",
"Erstellt mit Pflegepilot.",
"Erstellt mit MeinPflegeFuchs.",
]
return "\n".join(zeilen)
@@ -169,8 +198,7 @@ def build_adressaenderung_text(
versicherter_name: str,
geburtsdatum: datetime.date | None,
absender_telefon: str,
empfaenger_name: str,
empfaenger_adresse: str,
absender_email: str | None = None,
kennzeichen_label: str,
kennzeichen_wert: str | None,
alte_strasse: str | None,
@@ -180,11 +208,15 @@ def build_adressaenderung_text(
neue_plz: str,
neue_ort: str,
gueltig_ab: datetime.date | None,
unterzeichner: str | None = None,
) -> str:
"""Mitteilung einer Anschriftenaenderung an Krankenkasse oder Rentenver-
sicherung. Eine Funktion fuer beide Empfaengertypen, nur das Kennzeichen
(Versicherten- bzw. Versicherungsnummer) unterscheidet sich - vermeidet
Duplizierung von zwei fast identischen Briefvorlagen."""
Duplizierung von zwei fast identischen Briefvorlagen. Body-Fliesstext ab
Datum/Betreff - Absender/Empfaenger stehen nicht mehr hier, sondern werden
separat als absender/empfaenger ans DIN-5008-Fensterkuvert-Template
uebergeben (siehe routers/briefe.py)."""
heute = _de_datum(datetime.date.today())
geb_f = _de_datum(geburtsdatum) if geburtsdatum else "[Geburtsdatum]"
gueltig_f = _de_datum(gueltig_ab) if gueltig_ab else heute
@@ -195,14 +227,16 @@ def build_adressaenderung_text(
)
neue_adresse = f"{neue_strasse}, {neue_plz} {neue_ort}"
zeilen = [
versicherter_name, neue_strasse, f"{neue_plz} {neue_ort}",
]
kontaktzeilen = []
if absender_telefon:
zeilen.append(f"Tel.: {absender_telefon}")
zeilen += [
"", f"Datum: {heute}", "",
"An die:", empfaenger_name, empfaenger_adresse, "", "",
kontaktzeilen.append(f"Tel.: {absender_telefon}")
if absender_email:
kontaktzeilen.append(f"E-Mail: {absender_email}")
zeilen = [
f"Datum: {heute}", "",
*kontaktzeilen,
*([""] if kontaktzeilen else []),
"Betreff: Mitteilung einer Anschriftenänderung", "",
"Sehr geehrte Damen und Herren,", "",
"hiermit teile ich Ihnen einen Wohnsitzwechsel mit:", "",
@@ -215,8 +249,284 @@ def build_adressaenderung_text(
"Ich bitte Sie, meine Anschrift in Ihren Unterlagen entsprechend zu",
"aktualisieren und mir den Eingang dieser Mitteilung kurz zu bestätigen.", "",
"Mit freundlichen Grüßen,", "", "", "_____________________________",
versicherter_name, "",
*_unterschrift_zeilen(versicherter_name, unterzeichner), "",
"─" * 39,
"Erstellt mit Pflegepilot.",
"Erstellt mit MeinPflegeFuchs.",
]
return "\n".join(zeilen)
def build_vollmacht_text(
*,
patient_name: str,
geburtsdatum: datetime.date | None,
versichertennummer: str | None,
bevollmaechtigter_name: str,
bevollmaechtigter_adresse: str,
ort: str,
) -> str:
"""Vollmacht gemaess § 13 SGB X (Bevollmaechtigte und Beistaende im
Verwaltungsverfahren) - Tester-Feedback: Vollmacht-Generator fuer die
Krankenkasse, unterschrieben von Patient UND Bevollmaechtigtem. Umfang
orientiert sich an den Standard-Vollmachtsformularen der Krankenkassen
(z.B. AOK, Bertelsmann BKK) fuer Kranken-/Pflegekassenangelegenheiten.
Als regulaerer Geschaeftsbrief aufgebaut (Absender -> Datum -> Empfaenger
-> Betreff -> Anrede), damit die Struktur mit Widerspruch/Musterbrief/
Ummeldung konsistent ist, statt wie eine freistehende Urkunde mit der
Ueberschrift ganz oben zu beginnen. Absender/Empfaenger stehen nicht mehr
hier, sondern werden separat ans DIN-5008-Fensterkuvert-Template
uebergeben (siehe routers/briefe.py) - genau wie die beiden Unterschriften
und die Fusszeile, die dort als nebeneinanderliegende Spalten gerendert
werden (Kundenfeedback: beide Unterschriften nebeneinander statt
untereinander, damit die Vollmacht auf eine Seite passt)."""
heute = _de_datum(datetime.date.today())
geb_f = _de_datum(geburtsdatum) if geburtsdatum else "[Geburtsdatum]"
zeilen = [
f"Datum: {heute}", "",
"Betreff: Vollmacht gemäß § 13 SGB X", "",
"Sehr geehrte Damen und Herren,", "",
"hiermit bevollmächtige ich,", "",
patient_name,
f"geboren am {geb_f}",
]
if versichertennummer:
zeilen.append(f"Versichertennummer: {versichertennummer}")
zeilen += [
"", "die im Folgenden genannte Person:", "",
bevollmaechtigter_name, bevollmaechtigter_adresse, "", "",
"mich in allen Angelegenheiten gegenüber Ihnen zu vertreten.",
"Die Vollmacht umfasst insbesondere:", "",
"- das Stellen und Unterschreiben von Anträgen (z. B. auf Feststellung",
" eines Pflegegrads, Höherstufung, Leistungen der Pflegeversicherung),",
"- die Einlegung von Widersprüchen und sonstigen Rechtsbehelfen,",
"- die Entgegennahme von Bescheiden, Schreiben und sonstigen Mitteilungen,",
"- die Einholung von Auskünften sowie die Einsicht in Unterlagen,",
"- die Führung des allgemeinen Schriftverkehrs in Pflegeangelegenheiten.",
"",
"Diese Vollmacht gilt bis auf Widerruf.",
"", "",
f"{ort}, {heute}",
]
return "\n".join(zeilen)
def _option_text(optionen: list[dict], option_id: str | None) -> str | None:
for o in optionen:
if o["id"] == option_id:
return o.get("text")
return None
def build_patientenverfuegung_text(
*,
patient_name: str,
geburtsdatum: datetime.date | None,
wohnort: str,
situationen: list[str],
situation_eigene: str | None,
massnahmen: dict[str, str],
schmerzbehandlung_zusatz: bool,
wiederbelebung_a: str,
wiederbelebung_b: str,
ort: str,
beistand_personen: str | None,
beistand_kirche: str | None,
hospizlicher_beistand: bool,
schweigepflicht_personen: str | None,
vertrauensperson: str,
vertrauensperson_andere_name: str | None,
vollmacht_name: str | None,
betreuer_name: str | None,
organspende: str,
organspende_konflikt: str | None,
aufklaerungsverzicht: bool,
informiert_bei: str | None,
beraten_durch: str | None,
ausstellungsort: str,
) -> str:
"""Patientenverfuegung nach § 1901a BGB. Formulierungen sind, wo immer
moeglich, wortgetreu aus den offiziellen Textbausteinen der Broschuere
"Patientenverfuegung" des Bundesministeriums der Justiz (bmj.de, Stand
Januar 2023, Abschnitt 2) uebernommen - siehe
static_data/patientenverfuegung_textbausteine.py fuer die Begruendung,
warum das zulaessig ist. Anders als Widerspruch/Musterbrief/Ummeldung/
Vollmacht ist dies kein Brief an einen Empfaenger, sondern eine
eigenstaendige persoenliche Erklaerung - daher kein Absender/Empfaenger-
Fensterkuvert-Layout (siehe pdf_templates/patientenverfuegung.html) und
KEIN Unterzeichner-Wechsel bei hinterlegter Vollmacht: eine
Patientenverfuegung drueckt den eigenen Willen der betroffenen Person
aus und kann begrifflich nicht durch eine bevollmaechtigte Person an
ihrer Stelle unterschrieben werden."""
heute = _de_datum(datetime.date.today())
geb_f = _de_datum(geburtsdatum) if geburtsdatum else "[Geburtsdatum]"
zeilen = [
"PATIENTENVERFÜGUNG", "",
f"Ich, {patient_name}, geboren am {geb_f}, wohnhaft in {wohnort or '[Wohnort]'},",
"bestimme hiermit für den Fall, dass ich meinen Willen nicht mehr bilden oder",
"verständlich äußern kann:", "",
]
zeilen += ["1. SITUATIONEN, FÜR DIE DIESE VERFÜGUNG GELTEN SOLL", "", "Wenn"]
ausgewaehlte_situationen = [s for s in SITUATIONEN if s["id"] in situationen]
for s in ausgewaehlte_situationen:
zeilen.append(f"- {s['text']},")
if situation_eigene and situation_eigene.strip():
zeilen.append(f"- {situation_eigene.strip()},")
zeilen.append("")
zeilen += ["2. FESTLEGUNGEN ZU ÄRZTLICHEN/PFLEGERISCHEN MASSNAHMEN", ""]
for schluessel in ("lebenserhaltung",):
m = MASSNAHMEN[schluessel]
text = _option_text(m["optionen"], massnahmen.get(schluessel))
if text:
zeilen += [f"2.1 {m['label']}", "", m["einleitung"], f"- {text}.", ""]
zeilen += ["2.2 Wiederbelebung", ""]
a_text = _option_text(WIEDERBELEBUNG_A_OPTIONEN, wiederbelebung_a)
if a_text:
zeilen.append(f"In den oben beschriebenen Situationen wünsche ich {a_text}.")
a_opt = next((o for o in WIEDERBELEBUNG_A_OPTIONEN if o["id"] == wiederbelebung_a), None)
if a_opt and a_opt.get("zusatz_text"):
zeilen.append(f"Zusätzlich wünsche ich, {a_opt['zusatz_text']}.")
if wiederbelebung_b != "keine_regelung":
b_text = _option_text(WIEDERBELEBUNG_B_OPTIONEN, wiederbelebung_b)
if b_text:
zeilen.append(
f"Nicht nur in den oben beschriebenen Situationen, sondern in allen Fällen eines "
f"Kreislaufstillstands oder Atemversagens {b_text}."
)
zeilen.append("")
weitere_massnahmen = ("schmerzbehandlung", "ernaehrung", "beatmung", "dialyse", "antibiotika", "blut")
for nummer, schluessel in enumerate(weitere_massnahmen, start=3):
m = MASSNAHMEN[schluessel]
text = _option_text(m["optionen"], massnahmen.get(schluessel))
if not text:
continue
zeilen += [f"2.{nummer} {m['label']}", ""]
zeilen.append(f"{m['einleitung']} {text}.")
if schluessel == "schmerzbehandlung" and schmerzbehandlung_zusatz:
zusatz_text = m["zusatz"]["text"]
zeilen.append(f"{zusatz_text[0].upper()}{zusatz_text[1:]}.")
zeilen.append("")
# Ab hier laufende Nummerierung statt fester Ziffern: mehrere der
# folgenden Abschnitte sind optional (nur bei entsprechenden Angaben),
# eine fixe Nummerierung wuerde bei fehlenden Angaben Luecken in der
# Nummerierung erzeugen (z.B. "2. ... 5. ..." ohne 3./4.), was in einem
# rechtlich bedeutsamen Dokument wie einer Fehlstelle aussehen wuerde.
abschnitt = 2
ort_text = _option_text(ORT_OPTIONEN, ort)
if ort_text or beistand_personen or beistand_kirche or hospizlicher_beistand:
abschnitt += 1
zeilen += [f"{abschnitt}. ORT DER BEHANDLUNG, BEISTAND", ""]
if ort_text:
zeilen.append(f"Ich möchte {ort_text}.")
if beistand_personen and beistand_personen.strip():
zeilen.append(f"Ich wünsche Beistand durch folgende Personen: {beistand_personen.strip()}.")
if beistand_kirche and beistand_kirche.strip():
zeilen.append(
f"Ich wünsche Beistand durch eine Vertreterin oder einen Vertreter folgender Kirche oder "
f"Weltanschauungsgemeinschaft: {beistand_kirche.strip()}."
)
if hospizlicher_beistand:
zeilen.append("Ich wünsche hospizlichen Beistand.")
zeilen.append("")
if schweigepflicht_personen and schweigepflicht_personen.strip():
abschnitt += 1
zeilen += [
f"{abschnitt}. ENTBINDUNG VON DER ÄRZTLICHEN SCHWEIGEPFLICHT", "",
f"Ich entbinde die mich behandelnden Ärztinnen und Ärzte von der Schweigepflicht "
f"gegenüber folgenden Personen: {schweigepflicht_personen.strip()}.", "",
]
vp_text = _option_text(VERTRAUENSPERSON_OPTIONEN, vertrauensperson)
if vertrauensperson == "andere" and vertrauensperson_andere_name:
vp_text = vertrauensperson_andere_name
abschnitt += 1
zeilen += [
f"{abschnitt}. VERBINDLICHKEIT, AUSLEGUNG UND DURCHSETZUNG", "",
"Der in dieser Patientenverfügung geäußerte Wille zu bestimmten ärztlichen und "
"pflegerischen Maßnahmen soll von den behandelnden Ärztinnen und Ärzten und dem "
"Behandlungsteam befolgt werden. Mein(e) Vertreter(in) soll dafür Sorge tragen, dass "
"mein Patientenwille durchgesetzt wird.",
"",
"Sollte eine Ärztin oder ein Arzt oder das Behandlungsteam nicht bereit sein, meinen in "
"dieser Patientenverfügung geäußerten Willen zu befolgen, erwarte ich, dass für eine "
"anderweitige medizinische und/oder pflegerische Behandlung gesorgt wird.",
"",
"In Lebens- und Behandlungssituationen, die in dieser Patientenverfügung nicht konkret "
"geregelt sind, ist mein mutmaßlicher Wille möglichst im Konsens aller Beteiligten zu "
"ermitteln. Dafür soll diese Patientenverfügung als Richtschnur maßgeblich sein. Bei "
f"unterschiedlichen Meinungen soll der Auffassung {vp_text or '[Vertrauensperson]'} "
"besondere Bedeutung zukommen.",
"",
"Wenn ich meine Patientenverfügung nicht widerrufen habe, wünsche ich nicht, dass mir in "
"der konkreten Anwendungssituation eine Änderung meines Willens unterstellt wird.",
"",
]
if vollmacht_name or betreuer_name:
abschnitt += 1
zeilen += [f"{abschnitt}. HINWEISE AUF WEITERE VORSORGEVERFÜGUNGEN", ""]
if vollmacht_name:
zeilen.append(
f"Ich habe zusätzlich zur Patientenverfügung eine Vorsorgevollmacht für "
f"Gesundheitsangelegenheiten erteilt an: {vollmacht_name}. Den Inhalt dieser "
f"Patientenverfügung habe ich mit dieser Person besprochen."
)
if betreuer_name:
zeilen.append(
f"Ich habe eine Betreuungsverfügung erstellt und wünsche als Betreuerin/Betreuer: "
f"{betreuer_name}."
)
zeilen.append("")
if organspende in ("zustimmung", "ablehnung"):
abschnitt += 1
zeilen += [f"{abschnitt}. ORGANSPENDE", ""]
if organspende == "zustimmung":
zeilen.append("Ich stimme einer Entnahme meiner Organe nach meinem Tod zu Transplantationszwecken zu.")
konflikt_text = _option_text(ORGANSPENDE_KONFLIKT_OPTIONEN, organspende_konflikt)
if konflikt_text:
zeilen.append(
f"Komme ich nach ärztlicher Beurteilung bei einem sich abzeichnenden Hirntod als "
f"Organspender/in in Betracht und müssen dafür ärztliche Maßnahmen durchgeführt "
f"werden, die ich in dieser Patientenverfügung ausgeschlossen habe, dann {konflikt_text}."
)
else:
zeilen.append("Ich lehne eine Entnahme meiner Organe nach meinem Tod zu Transplantationszwecken ab.")
zeilen.append("")
abschnitt += 1
zeilen += [f"{abschnitt}. SCHLUSSFORMEL", ""]
if aufklaerungsverzicht:
zeilen.append(
"Soweit ich bestimmte Behandlungen wünsche oder ablehne, verzichte ich ausdrücklich auf "
"eine (weitere) ärztliche Aufklärung."
)
zeilen += [
"Mir ist die Möglichkeit der Änderung und des Widerrufs einer Patientenverfügung bekannt. "
"Ich bin mir des Inhalts und der Konsequenzen meiner darin getroffenen Entscheidungen "
"bewusst. Ich habe die Patientenverfügung in eigener Verantwortung und ohne äußeren Druck "
"erstellt. Ich bin im Vollbesitz meiner geistigen Kräfte.",
"",
]
if informiert_bei or beraten_durch:
abschnitt += 1
zeilen += [f"{abschnitt}. INFORMATION/BERATUNG", ""]
if informiert_bei:
zeilen.append(f"Ich habe mich vor der Erstellung dieser Patientenverfügung informiert bei/durch: {informiert_bei}.")
if beraten_durch:
zeilen.append(f"Ich habe mich beraten lassen durch: {beraten_durch}.")
zeilen.append("")
zeilen += ["", f"{ausstellungsort or '[Ort]'}, {heute}"]
return "\n".join(zeilen)
+107 -1
View File
@@ -1,5 +1,5 @@
"""
Pflegepilot v3 - Update-Verlauf (Entwickler-Seed).
MeinPflegeFuchs - Update-Verlauf (Entwickler-Seed).
Neuer Eintrag = ganz oben in CHANGELOG einfuegen (Liste ist immer
neueste-zuerst sortiert). Laufzeit-Quelle der Wahrheit ist die DB-Tabelle
@@ -13,6 +13,112 @@ Message.
"""
CHANGELOG = [
{
"version": "1.9.0",
"datum": "2026-07-29",
"titel": "Umkreissuche: 24h-Pflegedienste + Änderungsbenachrichtigung für Favoriten",
"punkte": [
"Neue Kategorie in der Umkreissuche: 24h-Pflegedienste.",
"Neu: ändern sich Kontaktdaten (Adresse, Telefon, Webseite, ...) eines gemerkten Umkreissuche-Favoriten, werden Sie automatisch per E-Mail informiert.",
],
},
{
"version": "1.8.0",
"datum": "2026-07-29",
"titel": "Krankenkassenkarte abfotografieren statt abtippen",
"punkte": [
"Neu beim Anlegen eines Patienten sowie im Bearbeiten-Formular: die Krankenkassenkarte abfotografieren – Name, Geburtsdatum, Versichertennummer und Krankenkasse werden automatisch erkannt und vorausgefüllt (bitte danach kurz prüfen).",
],
},
{
"version": "1.7.0",
"datum": "2026-07-29",
"titel": "Backup wiederherstellen",
"punkte": [
"Neu im Admin-Dashboard: eine zuvor heruntergeladene Backup-Datei lässt sich per Upload wieder einspielen (Datenbank plus hochgeladene Dateien) — für den Fall eines Totalausfalls.",
"Vor jeder Wiederherstellung wird automatisch eine Sicherheitskopie des aktuellen Zustands angelegt.",
],
},
{
"version": "1.6.0",
"datum": "2026-07-29",
"titel": "System-Backup im Admin-Bereich",
"punkte": [
"Neu im Admin-Dashboard: Backup-Funktion für die komplette Datenbank plus alle hochgeladenen Dateien (Scans, Briefe, Fotos) als eine herunterladbare Datei.",
"Läuft zusätzlich automatisch einmal täglich im Hintergrund, die letzten 5 Backups bleiben verfügbar.",
],
},
{
"version": "1.5.0",
"datum": "2026-07-29",
"titel": "Dokumenten-Scan per Handykamera, übersichtlicheres Dashboard",
"punkte": [
"Neu im Bereich „Dokumente“ einer Patientenakte: Dokumente direkt per Handy-/Tablet-Kamera einscannen statt eine Datei hochladen zu müssen. Mehrere Seiten werden automatisch zu einem PDF zusammengefasst, das Dokument wird automatisch zugeschnitten und entzerrt.",
"Dashboard überarbeitet: aufklappbare Patienten-Kacheln zeigen offene Anträge und Aufgaben direkt an, ohne die Seite zu verlassen.",
"Dashboard: die Kachel „Offene Anträge“ ist jetzt anklickbar und zeigt eine nach Patient sortierte Übersicht.",
],
},
{
"version": "1.4.0",
"datum": "2026-07-28",
"titel": "Neuer Generator: Patientenverfügung",
"punkte": [
"Neuer Dokument-Generator für die Patientenverfügung: ein Interview führt Schritt für Schritt durch die wichtigsten Festlegungen (Situationen, medizinische Maßnahmen wie künstliche Beatmung/Ernährung/Wiederbelebung/Schmerzbehandlung, Organspende, Bezug zu einer bereits hinterlegten Vollmacht).",
"Formulierungen basieren auf den offiziellen Textbausteinen des Bundesministeriums der Justiz.",
"Adresse bei Nutzer- und Patientenanlage: Ort wird nach Eingabe der Postleitzahl automatisch übernommen (und bei Änderung der PLZ automatisch aktualisiert).",
"Aufgaben mit Fälligkeitsdatum lassen sich jetzt direkt zu Apple Kalender oder Google Kalender hinzufügen.",
],
},
{
"version": "1.3.0",
"datum": "2026-07-28",
"titel": "Umkreissuche, Budget-Tracker, Krisen-Checklisten und mehr",
"punkte": [
"Neue Umkreissuche: Pflegedienste, Sanitätshäuser, Pflegeheime und weitere Wohnformen, sowie Logopädie/Physiotherapie/Reha-Zentren in der Nähe einer Postleitzahl finden – inklusive persönlicher Favoritenliste je Konto.",
"Neuer Budget-Tracker je Leistung: Restbetrag des Entlastungsbetrags im Blick behalten, inklusive Hinweis auf den Verfallstermin.",
"Neue Krisen-Checklisten: Schritt-für-Schritt-Anleitungen für Notfallsituationen (Hospitalisierung, Sturz, Tod des Angehörigen, Ausfall der Pflegeperson).",
"Neue Benachrichtigungen per E-Mail: bei Änderungen im Leistungskatalog sowie eine optional einstellbare tägliche Erinnerung fürs Pflegetagebuch.",
"Leistungskatalog: die 16 Bundesländer-spezifischen Regelungen (Landesblindengeld, Landespflegegeld u. Ä.) sind jetzt übersichtlich in einem eigenen Bereich „Bundeslandspezifische Regelungen“ gebündelt statt einzeln aufgelistet.",
],
},
{
"version": "1.2.0",
"datum": "2026-07-27",
"titel": "Checkliste, Leistungsauswahl, Vollmacht und mehr",
"punkte": [
"Versichertennummer wird jetzt sofort beim Eintippen auf das richtige Format geprüft (rot/grün), nicht erst beim Speichern.",
"Beim Erstellen von Musterbrief, Widerspruch und Ummeldung wird jetzt automatisch die eigene E-Mail-Adresse vorgeschlagen, falls die versicherte Person keine eigene hinterlegt hat.",
"Neue Checkliste bei Musterbrief und Widerspruch: zeigt an, welche Unterlagen für den jeweiligen Antrag üblicherweise gebraucht werden, inklusive Direkt-Upload per Klick.",
"Der \"Neuer Patient\"-Assistent fragt jetzt zusätzlich, welche Leistungen aus dem Leistungskatalog infrage kommen - und auf der Patientenseite gibt es einen neuen Bereich, um Leistungen jederzeit hinzuzufügen, zu deaktivieren oder zu entfernen.",
"Bei jeder Leistung lässt sich festlegen, ob man sie selbst übernimmt oder ob sie separat beantragt werden muss - bei Eigenleistung wird automatisch der mögliche Pflegepauschbetrag (Steuervorteil) angezeigt.",
"Neuer Vollmacht-Generator: erstellt eine Vollmacht gemäß § 13 SGB X zum Unterschreiben durch versicherte Person und Bevollmächtigte/n.",
"Sobald eine Vollmacht hinterlegt ist, unterschreiben nachfolgende Briefe (Widerspruch, Musterbrief, Ummeldung) automatisch \"in Vollmacht\" durch die bevollmächtigte Person.",
"Verbessertes Briefformat für den Druck mit Fensterkuvert (Absender- und Empfängeradresse sitzen jetzt korrekt im Kuvertfenster).",
"Info Tooltip unter Punkt 7 im Interview hinzugefügt",
],
},
{
"version": "1.1.0",
"datum": "2026-07-24",
"titel": "Pflegepilot heißt jetzt MeinPflegeFuchs",
"punkte": [
"Neuer Name, gleiche App: aus Pflegepilot wird MeinPflegeFuchs (bisherige Konten, Daten und Funktionen bleiben unverändert erhalten).",
"Der Namenswechsel war nötig, da es bereits mehrere unabhängige Apps und Dienste mit sehr ähnlichem Namen gab.",
],
},
{
# Versionsstring bewusst mit "Version "-Praefix, exakt wie bereits in
# der DB vorhanden (id 19) - sync_changelog() vergleicht den String
# exakt, eine "Bereinigung" auf "1.0.1" wuerde hier einen zweiten,
# doppelten Eintrag erzeugen statt den bestehenden zu erkennen.
"version": "Version 1.0.1",
"datum": "2026-07-24",
"titel": "Klein-Anpassungen",
"punkte": [
"Musterbrief-Generator erweitert für Adressänderungen",
"Preis-Staffelung eingeführt",
],
},
{
"version": "1.0.0",
"datum": "2026-07-19",
+22
View File
@@ -8,6 +8,28 @@ class Settings(BaseSettings):
secret_key: str
web_origin: str = "http://localhost:3100"
uploads_dir: str = "app/uploads"
# Eigenes Verzeichnis (eigenes Docker-Volume) statt eines Unterordners von
# uploads_dir - bewusste Trennung von Patienten-Dokumenten, damit ein
# Backup-Archiv nie versehentlich als "Patientendokument" auftaucht.
backups_dir: str = "app/backups"
# SMTP fuer serverseitigen Mailversand (aktuell nur Passwort-vergessen -
# alles andere in dieser App laeuft weiterhin ueber mailto, siehe
# CLAUDE.md "Email: mailto hand-off, not SMTP" fuer die Begruendung,
# warum das eine bewusste Ausnahme ist, keine stille Kehrtwende). Bleibt
# smtp_host leer, sendet email_util.send_email() keine Mail, sondern
# loggt nur - damit lokale Entwicklung ohne echten SMTP-Zugang moeglich
# bleibt und ein fehlender SMTP-Server keinen Endpunkt mit 500 abschiesst.
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str = "MeinPflegeFuchs <no-reply@meinpflegefuchs.de>"
# Genau eine von beiden darf/soll aktiv sein: smtp_use_ssl fuer
# implizites SSL (klassisch Port 465), smtp_use_tls fuer STARTTLS auf
# einer zunaechst unverschluesselten Verbindung (klassisch Port 587).
smtp_use_ssl: bool = False
smtp_use_tls: bool = True
@property
def web_origins(self) -> list[str]:
+146
View File
@@ -0,0 +1,146 @@
"""Kantenerkennung + Perspektivkorrektur fuer den Dokumenten-Scan (Handy-Foto
-> entzerrtes, PDF-taugliches Bild). Klassisches OpenCV-Rezept: die groesste,
(nahezu) vierseitige Kontur im Bild finden, auf vier Eckpunkte reduzieren,
das Bild darauf entzerren.
Bewusst serverseitig (Python/OpenCV) statt clientseitig (JavaScript/WASM,
z.B. OpenCV.js) - die App richtet sich laut Betreiber zu ca. 80% an Tablet-/
Handy-Nutzer/innen, denen sonst vor jeder Nutzung ein ca. 8-9 MB grosser
WASM-Download zugemutet wuerde. Serverseitig muss das Handy nur das rohe
Foto hochladen, die eigentliche Rechenarbeit laeuft auf dem Server.
Zwei Stufen der Kulanz, bevor der Upload jemals blockiert wird: findet sich
eine ausreichend grosse Kontur, die sich aber nicht sauber auf 4 Eckpunkte
reduzieren laesst (abgerundete Ecken, leichte Verzerrung), wird ersatzweise
deren umschliessendes gedrehtes Rechteck (`cv2.minAreaRect`) verwendet.
Findet sich ueberhaupt keine ausreichend grosse Kontur (schlechtes Licht,
unklarer Hintergrund, Dokument fuellt nicht das Bild), wird das Originalfoto
unveraendert weiterverwendet - explizite Betreiber-Entscheidung: lieber ein
unbeschnittenes Ergebnis als ein fehlgeschlagener Scan. Die Erkennungsschwelle
(MIN_KONTUR_ANTEIL) ist eine Heuristik, die in der Praxis (unterschiedliche
Lichtverhaeltnisse/Hintergruende) ggf. weiter nachjustiert werden muss - kein
Wert, der einmalig festgelegt und nie wieder angefasst wird.
"""
import io
import cv2
import numpy as np
from PIL import Image
# Kontur muss mindestens diesen Anteil der (verkleinerten) Bildflaeche
# abdecken, um als "das Dokument" statt als Rauschen/Hintergrundobjekt zu
# gelten.
MIN_KONTUR_ANTEIL = 0.2
class DokumentScanFehler(Exception):
"""Bild konnte nicht gelesen/kodiert werden (kaputte Datei, falsches
Format o.ae.) - fuer den Router, um daraus ein sauberes 400 zu machen."""
def _ordne_punkte(pts: np.ndarray) -> np.ndarray:
"""Ordnet 4 beliebig sortierte Punkte als
[oben-links, oben-rechts, unten-rechts, unten-links]."""
summe = pts.sum(axis=1)
diff = np.diff(pts, axis=1).flatten()
return np.array(
[
pts[np.argmin(summe)],
pts[np.argmin(diff)],
pts[np.argmax(summe)],
pts[np.argmax(diff)],
],
dtype="float32",
)
def _perspektive_entzerren(bild: np.ndarray, punkte: np.ndarray) -> np.ndarray:
tl, tr, br, bl = punkte
breite = int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))
hoehe = int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))
breite, hoehe = max(breite, 1), max(hoehe, 1)
ziel = np.array(
[[0, 0], [breite - 1, 0], [breite - 1, hoehe - 1], [0, hoehe - 1]], dtype="float32"
)
matrix = cv2.getPerspectiveTransform(punkte, ziel)
return cv2.warpPerspective(bild, matrix, (breite, hoehe))
def _auto_canny(bild_grau: np.ndarray, sigma: float = 0.33) -> np.ndarray:
"""Schwellenwerte aus dem Median der tatsaechlichen Pixelintensitaeten
ableiten statt fixer Werte (50/150) - robuster ueber unterschiedliche
Belichtung/Kontrast von Handyfotos hinweg, ein bekannter Schwachpunkt
von Canny mit festen Schwellenwerten."""
median = float(np.median(bild_grau))
unten = int(max(0, (1.0 - sigma) * median))
oben = int(min(255, (1.0 + sigma) * median))
return cv2.Canny(bild_grau, unten, oben)
def _dokument_kontur_finden(bild: np.ndarray) -> np.ndarray | None:
hoehe_original = bild.shape[0]
# Arbeitsaufloesung bewusst nicht zu klein waehlen (500px war zu grob und
# hat bei komplexerem Hintergrund zu schwache/unvollstaendige Kanten
# geliefert) - 700px als Kompromiss zwischen Detailtreue und Tempo.
skalierung = 700.0 / hoehe_original if hoehe_original > 700 else 1.0
klein = cv2.resize(bild, None, fx=skalierung, fy=skalierung)
grau = cv2.cvtColor(klein, cv2.COLOR_BGR2GRAY)
# Bilateraler statt Gauss'scher Weichzeichner: glaettet Papierstruktur/
# Bildrauschen, ohne die eigentliche Dokumentkante zu verwischen.
geglaettet = cv2.bilateralFilter(grau, 9, 75, 75)
kanten = _auto_canny(geglaettet)
# Schliesst kleine Luecken in der erkannten Kante (haeufig durch Schatten
# oder leichte Unschaerfe verursacht) zu einer durchgehenden Kontur.
kanten = cv2.morphologyEx(kanten, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8), iterations=2)
konturen, _ = cv2.findContours(kanten, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
konturen = sorted(konturen, key=cv2.contourArea, reverse=True)[:5]
bild_flaeche = klein.shape[0] * klein.shape[1]
for kontur in konturen:
if cv2.contourArea(kontur) < MIN_KONTUR_ANTEIL * bild_flaeche:
continue
umfang = cv2.arcLength(kontur, True)
# Mehrere Epsilon-Werte probieren statt nur einem festen (0.02) -
# reale, leicht verrauschte Fotos reduzieren sich damit deutlich
# zuverlaessiger auf genau 4 Eckpunkte.
for epsilon_faktor in (0.02, 0.03, 0.05, 0.08):
approx = cv2.approxPolyDP(kontur, epsilon_faktor * umfang, True)
if len(approx) == 4:
return _ordne_punkte(approx.reshape(4, 2).astype("float32")) / skalierung
# Kontur ist gross genug, laesst sich aber zu keinem sauberen Viereck
# reduzieren (z.B. abgerundete Ecken, leichte Verzerrung) - das
# umschliessende gedrehte Rechteck ist ein deutlich besserer Kompromiss
# als die Erkennung fuer diese Kontur komplett aufzugeben.
box = cv2.boxPoints(cv2.minAreaRect(kontur))
return _ordne_punkte(box.astype("float32")) / skalierung
return None
def erkenne_und_entzerre(bild_bytes: bytes) -> bytes:
arr = np.frombuffer(bild_bytes, dtype=np.uint8)
bild = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bild is None:
raise DokumentScanFehler("Bild konnte nicht gelesen werden")
punkte = _dokument_kontur_finden(bild)
ergebnis = _perspektive_entzerren(bild, punkte) if punkte is not None else bild
ok, kodiert = cv2.imencode(".jpg", ergebnis, [cv2.IMWRITE_JPEG_QUALITY, 90])
if not ok:
raise DokumentScanFehler("Bild konnte nicht kodiert werden")
return kodiert.tobytes()
def bilder_zu_pdf(bilder_bytes: list[bytes]) -> bytes:
"""Fasst mehrere (bereits entzerrte) Fotos zu einem mehrseitigen PDF
zusammen - eine Scan-Session kann mehrere Seiten eines Dokuments sein
(z.B. ein mehrseitiger Bescheid oder Arztbericht)."""
seiten = [Image.open(io.BytesIO(b)).convert("RGB") for b in bilder_bytes]
puffer = io.BytesIO()
seiten[0].save(puffer, format="PDF", save_all=True, append_images=seiten[1:])
return puffer.getvalue()
+169
View File
@@ -0,0 +1,169 @@
"""OCR-Auslesen der eGK (elektronische Gesundheitskarte) fuer Auto-Fill bei
Patientenanlage/-bearbeitung (Tester-Feedback T8) - siehe CLAUDE.md,
Abschnitt "eGK-Scan / OCR-Kartenerfassung" fuer die Entscheidung "Tesseract
statt Cloud-Vision-API" (kostenlos, offline, Kartenfoto verlaesst nie den
eigenen Server; Kehrseite: geringere Erkennungsgenauigkeit als eine
Cloud-Loesung).
Bewusst nur GKV (elektronische Gesundheitskarte) - die eGK hat ein von
gematik/GKV-Spitzenverband standardisiertes Layout (feste Feldbezeichnungen,
Versicherten-ID immer 1 Buchstabe + 9 Ziffern), das eine regelbasierte
Extraktion ueberhaupt erst realistisch macht. PKV-Karten haben kein
einheitliches Layout und werden bewusst nicht unterstuetzt (Betreiber-
Entscheidung).
Wie beim Dokumenten-Scan (app/dokument_scan.py): kein Anspruch auf perfekte
Erkennung, alle Felder bleiben im Formular manuell ueberschreibbar - eine
verpasste/falsche Erkennung blockiert nie die Nutzung, sie spart im
Erfolgsfall nur Tipparbeit. Die Kartenkontur-Erkennung/Entzerrung wird 1:1
von dokument_scan.erkenne_und_entzerre() wiederverwendet, statt eine zweite
Kantenerkennung zu schreiben - eine eGK ist geometrisch nur ein kleineres
Rechteck als ein A4-Dokument, dieselbe Heuristik passt.
Ohne echte Testfotos verifiziert (wie urspruenglich beim Dokumenten-Scan) -
die Feld-Heuristiken (insb. Name/Vorname-Zeilenerkennung) sollten nach den
ersten echten Nutzungen erneut geprueft werden, siehe die dortige Lehre
("Kantenerkennung ist mangelhaft" -> Nachbesserung anhand echter Fotos).
"""
import difflib
import re
import cv2
import numpy as np
import pytesseract
from app import dokument_scan
from app.static_data.krankenkassen import KRANKENKASSEN
# Versicherten-ID: 1 Buchstabe + 9 Ziffern, gematik-weit einheitliches Format.
_VERSICHERTEN_ID_MUSTER = re.compile(r"\b[A-Z]\d{9}\b")
# Datum im Format TT.MM.JJJJ (auch mit Leerzeichen statt Punkt, falls OCR die
# Punkte verschluckt).
_DATUM_MUSTER = re.compile(r"\b(\d{1,2})[.\s](\d{1,2})[.\s](\d{4})\b")
# "Name"/"Vorname" als aufgedrucktes Feldlabel - Wert steht entweder direkt
# dahinter in derselben Zeile oder (haeufiger bei OCR-Zeilenzerlegung) in der
# Zeile danach.
_NAME_LABEL_MUSTER = re.compile(r"^(vorname|name|nachname)\s*[:\-]?\s*(.*)$", re.IGNORECASE)
class EgkScanFehler(Exception):
"""Bild konnte nicht gelesen werden - fuer den Router, um daraus ein
sauberes 400 zu machen."""
def _text_erkennen(entzerrtes_bild_bytes: bytes) -> str:
arr = np.frombuffer(entzerrtes_bild_bytes, dtype=np.uint8)
bild = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bild is None:
raise EgkScanFehler("Bild konnte nicht gelesen werden")
grau = cv2.cvtColor(bild, cv2.COLOR_BGR2GRAY)
# Adaptive statt globaler Schwellenwertbildung: gleichmaessigere
# Kontrastierung von Kartentext gegen den Kartenhintergrund, robuster
# gegenueber ungleichmaessiger Beleuchtung als ein fixer Schwellenwert.
schwelle = cv2.adaptiveThreshold(
grau, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15
)
return pytesseract.image_to_string(schwelle, lang="deu")
def _versichertennummer_erkennen(text: str) -> str | None:
treffer = _VERSICHERTEN_ID_MUSTER.search(text.upper())
return treffer.group(0) if treffer else None
def _geburtsdatum_erkennen(text: str) -> str | None:
treffer = _DATUM_MUSTER.search(text)
if not treffer:
return None
tag, monat, jahr = treffer.groups()
try:
tag_i, monat_i, jahr_i = int(tag), int(monat), int(jahr)
if not (1 <= tag_i <= 31 and 1 <= monat_i <= 12 and 1900 <= jahr_i <= 2100):
return None
return f"{jahr_i:04d}-{monat_i:02d}-{tag_i:02d}"
except ValueError:
return None
def _namen_erkennen(text: str) -> tuple[str | None, str | None]:
zeilen = [z.strip() for z in text.splitlines() if z.strip()]
vorname = nachname = None
for i, zeile in enumerate(zeilen):
treffer = _NAME_LABEL_MUSTER.match(zeile)
if not treffer:
continue
label = treffer.group(1).lower()
rest = treffer.group(2).strip()
wert = rest if len(rest) >= 2 else (zeilen[i + 1] if i + 1 < len(zeilen) else None)
if not wert:
continue
if label == "vorname" and vorname is None:
vorname = wert
elif label in ("name", "nachname") and nachname is None:
nachname = wert
return vorname, nachname
_KASSE_MIN_PUNKTZAHL = 0.72
def _kasse_erkennen(text: str) -> str | None:
"""Gleicht OCR-Zeilen per Fuzzy-Matching (difflib) gegen die bestehende
Krankenkassen-Stammdatenliste ab, statt auf eine exakte Texterkennung des
(oft als Logo statt als Text gedruckten) Kassennamens zu hoffen.
Zwei Sicherungen gegen Fehltreffer (real per Testbild aufgefallen: eine
kurze, komplett unpassende Zeile wie "Mustermann" wurde ueber
difflib.get_close_matches() mit niedrigem Cutoff faelschlich als
"Barmer" erkannt): (1) ein Laengenunterschied von mehr als 4 Zeichen
zwischen OCR-Zeile und Kandidatennamen wird gar nicht erst verglichen -
SequenceMatcher-Werte werden bei sehr unterschiedlich langen Strings
unzuverlaessig; (2) ein fester Mindest-Punktzahl-Schwellenwert
(statt nur "bester Kandidat, egal wie schwach") - lieber kein Treffer als
ein falscher."""
zeilen = [z.strip() for z in text.splitlines() if len(z.strip()) >= 3]
kandidaten: dict[str, str] = {}
for k in KRANKENKASSEN:
kandidaten[k["name"]] = k["name"]
if k.get("kurzname"):
kandidaten[k["kurzname"]] = k["name"]
bester_name: str | None = None
beste_punktzahl = 0.0
for zeile in zeilen:
for kandidat_name in kandidaten:
if abs(len(zeile) - len(kandidat_name)) > 4:
continue
punktzahl = difflib.SequenceMatcher(None, zeile.lower(), kandidat_name.lower()).ratio()
if punktzahl > beste_punktzahl:
beste_punktzahl = punktzahl
bester_name = kandidaten[kandidat_name]
return bester_name if beste_punktzahl >= _KASSE_MIN_PUNKTZAHL else None
def erkenne_egk(bilder_bytes: list[bytes]) -> dict:
"""Verarbeitet 1-2 Kartenfotos (z.B. Vorder-/Rueckseite der eGK) und
liefert die gefundenen Felder zurueck - jedes Feld ist None, wenn nichts
erkannt werden konnte. Keine Persistierung der Fotos selbst (Verarbeitung
ausschliesslich im Arbeitsspeicher) - anders als beim Dokumenten-Scan
gibt es hier keinen fachlichen Grund, das Kartenfoto selbst aufzuheben."""
text_teile = []
for b in bilder_bytes:
try:
entzerrt = dokument_scan.erkenne_und_entzerre(b)
except dokument_scan.DokumentScanFehler as e:
raise EgkScanFehler(str(e)) from e
text_teile.append(_text_erkennen(entzerrt))
gesamt_text = "\n".join(text_teile)
vorname, nachname = _namen_erkennen(gesamt_text)
return {
"vorname": vorname,
"nachname": nachname,
"geburtsdatum": _geburtsdatum_erkennen(gesamt_text),
"versichertennummer": _versichertennummer_erkennen(gesamt_text),
"krankenkasse": _kasse_erkennen(gesamt_text),
}
+18 -4
View File
@@ -36,12 +36,26 @@ def abo_pflicht_aktiv(db: Session) -> bool:
return get_setting(db, "abo_pflicht", "0") == "1"
def _preis_decimal(rohwert: str | None, default: str) -> decimal.Decimal:
"""Parst einen in den Einstellungen gespeicherten Preis-String robust:
akzeptiert sowohl deutsches Komma ("4,99") als auch Punkt ("4.99") als
Dezimaltrennzeichen, und faellt bei jedem sonstigen ungueltigen Wert auf
den Default zurueck. Notwendig, weil das Admin-Einstellungen-Formular ein
einfaches Textfeld ist - ein Admin, der versehentlich mit deutschem Komma
speichert, darf damit nicht die komplette Nutzerliste/Preisberechnung mit
einem 500er lahmlegen (siehe CLAUDE.md, Vorfall 2026-07-24)."""
if rohwert:
try:
return decimal.Decimal(rohwert.strip().replace(",", "."))
except decimal.InvalidOperation:
pass
return decimal.Decimal(default)
def abo_preise(db: Session) -> tuple[decimal.Decimal, decimal.Decimal]:
"""(Basispreis, Aufpreis je zusaetzlichem Patienten) aus den Einstellungen."""
basis = decimal.Decimal(get_setting(db, "abo_preis_basis", ABO_PREIS_BASIS_DEFAULT))
zusatz = decimal.Decimal(
get_setting(db, "abo_preis_zusatzpatient", ABO_PREIS_ZUSATZPATIENT_DEFAULT)
)
basis = _preis_decimal(get_setting(db, "abo_preis_basis"), ABO_PREIS_BASIS_DEFAULT)
zusatz = _preis_decimal(get_setting(db, "abo_preis_zusatzpatient"), ABO_PREIS_ZUSATZPATIENT_DEFAULT)
return basis, zusatz
+58
View File
@@ -0,0 +1,58 @@
"""
Serverseitiger SMTP-Mailversand. Urspruenglich als einzige Ausnahme zum
"mailto statt SMTP"-Prinzip dieser App gedacht (Passwort-Reset, da nur der
Server selbst und ohne Nutzerinteraktion verschicken kann) - seit 2026-07-28
mit Betreiber-Freigabe erweitert um proaktive Benachrichtigungen, bei denen
es strukturell keine Nutzeraktion gibt, an die ein mailto-Link haengen
koennte (z.B. Katalog-Aenderungsbenachrichtigung, siehe katalog_watch.py).
Musterbrief/Widerspruch/Ummeldung/Vollmacht bleiben bewusst beim mailto-
Modell - siehe CLAUDE.md "Email: mailto hand-off, not SMTP" fuer die
vollstaendige, aktuelle Abgrenzung.
"""
import logging
import smtplib
import ssl
from email.mime.text import MIMEText
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, body: str) -> None:
"""Versendet eine reine Text-E-Mail. Ist noch kein SMTP konfiguriert
(smtp_host leer) oder schlaegt der Versand fehl, wird das nur geloggt,
nicht als Exception nach oben gereicht - ein aufrufender Endpunkt wie
Passwort-vergessen soll unabhaengig vom E-Mail-Versand immer dieselbe
Antwort liefern (siehe routers/auth.py), sonst liesse sich am
Antwortverhalten erkennen, ob eine E-Mail-Adresse existiert."""
if not settings.smtp_host:
logger.warning("SMTP nicht konfiguriert - E-Mail an %s wurde NICHT gesendet: %s", to, subject)
return
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to
try:
if settings.smtp_use_ssl:
# Implizites SSL/TLS von der ersten Verbindung an (klassisch
# "SMTPS", ueblicherweise Port 465) - nicht zu verwechseln mit
# STARTTLS unten, das auf einer zunaechst unverschluesselten
# Verbindung (ueblicherweise Port 587) aufsetzt.
context = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=10, context=context) as server:
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as server:
if settings.smtp_use_tls:
server.starttls()
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
except Exception:
logger.exception("E-Mail-Versand an %s fehlgeschlagen", to)
+131
View File
@@ -0,0 +1,131 @@
"""Erkennt inhaltliche Aenderungen an KATALOG-Eintraegen (Betrag,
Voraussetzung, Rechtsgrundlage) zwischen App-Starts und benachrichtigt jeden
Nutzer sowie - falls hinterlegt - den Patienten selbst per E-Mail, wenn eine
ihnen zugewiesene Leistung sich geaendert hat (Tester-Feedback 2026-07-27:
"Benachrichtigungs-Funktion via E-Mail an den Nutzer parallel an den
Patienten falls sich der Leistungskatalog aktualisiert").
Das ist die zweite bewusste Ausnahme vom "mailto statt SMTP"-Prinzip dieser
App (nach dem Passwort-Reset, siehe email_util.py) - ein neuer/geaenderter
Katalogeintrag kann nur vom Server selbst erkannt werden, es gibt keine
Nutzeraktion, an die man den Versand haengen koennte. Vom Betreiber am
2026-07-28 ausdruecklich freigegeben (siehe CLAUDE.md).
Laeuft beim App-Start (main.py: lifespan), NICHT bei jedem Request. Gunicorn
startet mehrere Worker-Prozesse, die den Startup-Code alle gleichzeitig
ausfuehren (siehe die Changelog-Duplikat-Lehre in CLAUDE.md) - hier reicht ein
reines "ON CONFLICT DO NOTHING" aber nicht, weil zwischen Vergleich und
Update ein Seiteneffekt (Mailversand) haengt, der sonst doppelt ausgeloest
werden koennte. Deshalb ein Postgres-Advisory-Lock: nur der Worker, der ihn
zuerst bekommt, fuehrt den Abgleich durch; die anderen ueberspringen ihn fuer
diesen Start komplett (voellig ausreichend, da der Abgleich nur einmal nach
einem Deploy passieren muss, nicht einmal pro Worker)."""
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.email_util import send_email
from app.models import KatalogSnapshot, Leistung, Nutzer, Patient
from app.static_data.katalog import KATALOG
logger = logging.getLogger(__name__)
_ADVISORY_LOCK_ID = 895623147 # beliebige, fest gewaehlte Konstante fuer diesen Lock
def sync_katalog_und_benachrichtigen(db: Session) -> None:
got_lock = db.execute(
text("SELECT pg_try_advisory_lock(:id)"), {"id": _ADVISORY_LOCK_ID}
).scalar()
if not got_lock:
logger.info("Katalog-Abgleich laeuft bereits in einem anderen Worker - uebersprungen.")
return
try:
_sync_und_benachrichtigen(db)
finally:
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": _ADVISORY_LOCK_ID})
def _relevante_werte(eintrag: dict) -> tuple[str | None, str | None, str | None]:
return (eintrag.get("betrag"), eintrag.get("voraussetzung"), eintrag.get("rechtsgrundlage"))
def _sync_und_benachrichtigen(db: Session) -> None:
for eintrag in KATALOG:
katalog_id = eintrag["id"]
neue_werte = _relevante_werte(eintrag)
snapshot = db.get(KatalogSnapshot, katalog_id)
if snapshot is None:
# Erster bekannter Stand - keine Benachrichtigung (sonst wuerde
# der komplette Katalog beim allerersten Start als "geaendert"
# gemeldet).
db.add(
KatalogSnapshot(
katalog_id=katalog_id,
betrag=neue_werte[0],
voraussetzung=neue_werte[1],
rechtsgrundlage=neue_werte[2],
)
)
continue
alte_werte = (snapshot.betrag, snapshot.voraussetzung, snapshot.rechtsgrundlage)
if alte_werte != neue_werte:
_benachrichtige_betroffene(db, eintrag, alte_werte, neue_werte)
snapshot.betrag, snapshot.voraussetzung, snapshot.rechtsgrundlage = neue_werte
db.commit()
def _benachrichtige_betroffene(
db: Session,
eintrag: dict,
alte_werte: tuple[str | None, str | None, str | None],
neue_werte: tuple[str | None, str | None, str | None],
) -> None:
leistungen = db.query(Leistung).filter(Leistung.katalog_id == eintrag["id"]).all()
if not leistungen:
return
# E-Mail -> Liste der betroffenen Patientennamen, dedupliziert - ein
# Nutzer mit mehreren Patienten, die dieselbe Leistung haben, bekommt so
# eine einzige Mail statt mehrerer.
empfaenger: dict[str, list[str]] = {}
for leistung in leistungen:
patient: Patient = leistung.patient
nutzer: Nutzer = patient.nutzer
name = f"{patient.vorname} {patient.nachname}"
for email in filter(None, {nutzer.email, patient.email}):
namen = empfaenger.setdefault(email, [])
if name not in namen:
namen.append(name)
alt_betrag, alt_voraussetzung, _ = alte_werte
neu_betrag, neu_voraussetzung, _ = neue_werte
for email, namen in empfaenger.items():
betreff = f"MeinPflegeFuchs – Änderung bei „{eintrag['name']}“"
text_zeilen = [
"Hallo,",
"",
f"für folgende von Ihnen verwaltete Person(en) ist die Leistung „{eintrag['name']}“ hinterlegt: "
+ ", ".join(namen) + ".",
"",
"Diese Leistung hat sich im Leistungskatalog geändert:",
]
if alt_betrag != neu_betrag:
text_zeilen.append(f" Bisheriger Richtwert: {alt_betrag or '–'}")
text_zeilen.append(f" Neuer Richtwert: {neu_betrag or '–'}")
if alt_voraussetzung != neu_voraussetzung:
text_zeilen.append(f" Bisherige Voraussetzung: {alt_voraussetzung or '–'}")
text_zeilen.append(f" Neue Voraussetzung: {neu_voraussetzung or '–'}")
text_zeilen += [
"",
"Bitte prüfen Sie im Leistungskatalog in MeinPflegeFuchs, ob sich dadurch etwas für Sie ändert.",
"",
"Erstellt mit MeinPflegeFuchs.",
]
send_email(email, betreff, "\n".join(text_zeilen))
+19 -1
View File
@@ -4,7 +4,11 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app import backup_scheduler
from app import changelog as changelog_logik
from app import katalog_watch
from app import reminder_scheduler
from app import umkreis_favoriten_watch
from app.config import settings
from app.db import SessionLocal
from app.routers import (
@@ -17,13 +21,18 @@ from app.routers import (
changelog,
dashboard,
dokumente,
egk,
einstellungen,
guides,
kalender,
katalog,
leistungen,
patienten,
pflegegrad,
plz,
stammdaten,
tagebuch,
umkreissuche,
)
@@ -32,12 +41,16 @@ async def lifespan(app: FastAPI):
db = SessionLocal()
try:
changelog_logik.sync_changelog(db)
katalog_watch.sync_katalog_und_benachrichtigen(db)
finally:
db.close()
reminder_scheduler.starte_reminder_scheduler()
backup_scheduler.starte_backup_scheduler()
umkreis_favoriten_watch.starte_umkreis_favoriten_scheduler()
yield
app = FastAPI(title="Pflegepilot API", lifespan=lifespan)
app = FastAPI(title="MeinPflegeFuchs API", lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
@@ -80,8 +93,13 @@ app.include_router(einstellungen.router)
app.include_router(changelog.router)
app.include_router(tagebuch.router)
app.include_router(dokumente.router)
app.include_router(leistungen.router)
app.include_router(aufgaben.router)
app.include_router(kalender.router)
app.include_router(guides.router)
app.include_router(umkreissuche.router)
app.include_router(plz.router)
app.include_router(egk.router)
@app.get("/api/health")
+108 -1
View File
@@ -2,6 +2,7 @@ import datetime
import decimal
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Date,
@@ -10,6 +11,8 @@ from sqlalchemy import (
Numeric,
String,
Text,
Time,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
@@ -26,6 +29,9 @@ class Nutzer(Base):
benutzername: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
passwort_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(200))
email: Mapped[str | None] = mapped_column(String(255), unique=True)
reset_token_hash: Mapped[str | None] = mapped_column(String(64))
reset_token_laeuft_ab: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
rolle: Mapped[str] = mapped_column(String(20), nullable=False, default="nutzer")
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
abo_bis: Mapped[datetime.date | None] = mapped_column(Date)
@@ -40,9 +46,19 @@ class Nutzer(Base):
rg_plz: Mapped[str | None] = mapped_column(String(20))
rg_ort: Mapped[str | None] = mapped_column(String(100))
selbst_registriert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
widerrufsverzicht_bestaetigt_am: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True)
)
erstellt_am: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# Tagebuch-Erinnerungs-Mail (Tester-Feedback: "Wie war der Tag mit Oma
# heute?" - taegliche Check-in-Erinnerung). reminder_zuletzt_gesendet
# verhindert Mehrfachversand am selben Tag (der Scheduler prueft alle
# paar Minuten, nicht exakt zur eingestellten Uhrzeit).
reminder_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
reminder_uhrzeit: Mapped[datetime.time | None] = mapped_column(Time)
reminder_zuletzt_gesendet: Mapped[datetime.date | None] = mapped_column(Date)
patienten: Mapped[list["Patient"]] = relationship(back_populates="nutzer")
@@ -79,6 +95,9 @@ class Patient(Base):
pflegeperson: Mapped[str | None] = mapped_column(String(200))
zielgruppe: Mapped[str] = mapped_column(String(20), nullable=False, default="alterspflege")
notizen: Mapped[str | None] = mapped_column(Text)
berechtigung_bestaetigt_am: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True)
)
erstellt_am: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -117,7 +136,8 @@ class Antrag(Base):
__tablename__ = "antraege"
__table_args__ = (
CheckConstraint(
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung')", name="ck_antrag_typ"
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung','vollmacht','patientenverfuegung')",
name="ck_antrag_typ",
),
CheckConstraint(
"status IN ('offen','eingereicht','in_pruefung','genehmigt','abgelehnt')",
@@ -149,6 +169,11 @@ class Antrag(Base):
class Leistung(Base):
__tablename__ = "leistungen"
__table_args__ = (
CheckConstraint(
"rolle IN ('eigenleistung','extern_beantragen')", name="ck_leistung_rolle"
),
)
id: Mapped[int] = mapped_column(primary_key=True)
patient_id: Mapped[int] = mapped_column(
@@ -162,8 +187,37 @@ class Leistung(Base):
seit: Mapped[datetime.date | None] = mapped_column(Date)
katalog_id: Mapped[str | None] = mapped_column(String(50))
notizen: Mapped[str | None] = mapped_column(Text)
# Rollenverteilung (Tester-Feedback: "was kann ich leisten, was will ich
# leisten") - NULL solange noch nicht festgelegt. "eigenleistung": der
# Primaernutzer erbringt/uebernimmt das selbst (typischerweise haeusliche
# Pflege statt Sachleistung). "extern_beantragen": muss separat bei der
# zustaendigen Stelle beantragt/organisiert werden.
rolle: Mapped[str | None] = mapped_column(String(30))
# Budget-Tracker (Tester-Feedback: "Du hast diesen Monat noch 125 €
# Entlastungsbetrag uebrig"). Jaehrliches Budget in Euro, NULL solange
# nicht eingetragen - v.a. fuer Leistungen mit Jahresbudget gedacht
# (Entlastungsbetrag, Verhinderungspflege, Kurzzeitpflege), aber bewusst
# nicht auf einen bestimmten katalog_id beschraenkt.
budget_jahr: Mapped[decimal.Decimal | None] = mapped_column(Numeric(10, 2))
patient: Mapped["Patient"] = relationship(back_populates="leistungen")
ausgaben: Mapped[list["LeistungAusgabe"]] = relationship(
back_populates="leistung", cascade="all, delete-orphan"
)
class LeistungAusgabe(Base):
__tablename__ = "leistung_ausgaben"
id: Mapped[int] = mapped_column(primary_key=True)
leistung_id: Mapped[int] = mapped_column(
ForeignKey("leistungen.id", ondelete="CASCADE"), nullable=False
)
betrag: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2), nullable=False)
datum: Mapped[datetime.date] = mapped_column(Date, nullable=False)
beschreibung: Mapped[str | None] = mapped_column(String(300))
leistung: Mapped["Leistung"] = relationship(back_populates="ausgaben")
class Aufgabe(Base):
@@ -284,3 +338,56 @@ class ChangelogEintrag(Base):
erstellt_am: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class KatalogSnapshot(Base):
"""Letzter bekannter Stand (Betrag/Voraussetzung/Rechtsgrundlage) jedes
KATALOG-Eintrags aus static_data/katalog.py, verglichen bei jedem
App-Start (siehe app/katalog_watch.py). Tester-Feedback: Nutzer und
Patient sollen benachrichtigt werden, wenn sich eine zugewiesene
Leistung inhaltlich aendert (Betrag, Voraussetzung, Rechtsgrundlage).
katalog_id ist bewusst Primary Key (kein Autoincrement) - ein Eintrag pro
Katalog-ID, direkt per ID nachschlagbar."""
__tablename__ = "katalog_snapshots"
katalog_id: Mapped[str] = mapped_column(String(50), primary_key=True)
betrag: Mapped[str | None] = mapped_column(Text)
voraussetzung: Mapped[str | None] = mapped_column(Text)
rechtsgrundlage: Mapped[str | None] = mapped_column(Text)
aktualisiert_am: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UmkreissucheFavorit(Base):
"""Vom Nutzer gemerkter Treffer aus der Umkreissuche (T10-T13) - bewusst
je Nutzerkonto statt je Patient (Absprache mit dem Betreiber), da ein
gefundener Pflegedienst/Sanitaetshaus/etc. typischerweise fuer alle vom
Konto verwalteten Patienten gleichermassen relevant ist, nicht nur fuer
einen einzelnen. osm_type+osm_id identifizieren den OSM-Datensatz
eindeutig und dienen als Idempotenz-/Dedupe-Schluessel (UniqueConstraint),
Name/Adresse/Kontakt werden zum Zeitpunkt des Merkens kopiert, damit ein
Favorit auch dann noch anzeigbar ist, wenn Overpass gerade nicht
erreichbar ist oder der Eintrag in OSM zwischenzeitlich verschwindet."""
__tablename__ = "umkreissuche_favoriten"
__table_args__ = (
UniqueConstraint("nutzer_id", "osm_type", "osm_id", name="uq_umkreissuche_favorit"),
)
id: Mapped[int] = mapped_column(primary_key=True)
nutzer_id: Mapped[int] = mapped_column(ForeignKey("nutzer.id", ondelete="CASCADE"), nullable=False)
osm_type: Mapped[str] = mapped_column(String(10), nullable=False)
osm_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
name: Mapped[str] = mapped_column(String(300), nullable=False)
kategorie: Mapped[str] = mapped_column(String(50), nullable=False)
strasse: Mapped[str | None] = mapped_column(String(300))
plz: Mapped[str | None] = mapped_column(String(10))
ort: Mapped[str | None] = mapped_column(String(200))
telefon: Mapped[str | None] = mapped_column(String(100))
email: Mapped[str | None] = mapped_column(String(255))
webseite: Mapped[str | None] = mapped_column(String(500))
erstellt_am: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
@@ -2,9 +2,109 @@
<html>
<head>
<style>
@page { size: A4; margin: 25mm 20mm 28mm 25mm; }
body { font-family: "Courier New", Courier, monospace; font-size: 10.5pt; line-height: 1.9; color: #111; white-space: pre-wrap; }
@page { size: A4; margin: 20mm; }
/* white-space:pre-wrap bewusst NICHT auf body, sondern nur auf den
Elementen, deren Inhalt es wirklich braucht (.empfaenger, .brief-body):
auf body geerbt wuerde es auch die belanglosen Zeilenumbrueche ZWISCHEN
den HTML-Tags im Quelltext selbst sichtbar machen (die normalerweise
kollabieren), was zusaetzlichen, unsichtbaren Leerraum vor dem
Fliesstext erzeugt (gemessen ca. 18mm) - genau das durch das Spacer-Div
kaschierte Bug-Symptom aus dem Tester-Feedback. */
body {
position: relative;
margin: 0;
font-family: "DejaVu Sans", Arial, Helvetica, sans-serif;
font-size: 11pt;
line-height: 1.3;
color: #111;
}
/* DIN 5008 Anschriftfenster (Fensterkuvert), gemessen ab Papierkante:
Fenster gesamt 20mm links, 45mm oben, 85mm breit, 45mm hoch. Die ersten
5mm des Fensters sind die Zusatz-/Vermerkzone, dort sitzt konventionell
die kleine Absenderzeile; die eigentliche Anschrift beginnt bei 50mm.
Seiten-Rand ist 20mm links/oben, top/left hier sind relativ zur Body-
Content-Box (=Papierkante minus 20mm Seitenrand). */
.absenderzeile {
position: absolute;
top: 25mm;
left: 0;
width: 85mm;
font-size: 7pt;
line-height: 1.2;
text-decoration: underline;
color: #333;
white-space: nowrap;
overflow: hidden;
}
.empfaenger {
position: absolute;
top: 30mm;
left: 0;
width: 85mm;
font-size: 11pt;
line-height: 1.4;
white-space: pre-line;
}
/* Schiebt den Fliesstext unter das Anschriftfenster - nur auf Seite 1
wirksam, da es ein einmaliges Element im normalen Textfluss ist. Fenster
endet bei 90mm ab Papierkante (45mm oben + 45mm hoch); .empfaenger ist
absolut positioniert und wird von diesem Spacer nicht automatisch
mitgemessen, daher ist der Wert bewusst auf den ueblichen Fall (Absender-
zeile + 3-4-zeiliger Empfaenger) abgestimmt statt auf den theoretischen
Maximalfall - bei einer ungewoehnlich langen Adresse (>4 Zeilen) koennte
der Fliesstext das Adressfeld knapp ueberlappen. */
.spacer { height: 60mm; }
.brief-body { white-space: pre-wrap; }
/* Nebeneinanderliegende Unterschriftsfelder (statt untereinander), damit
Briefe mit mehreren Unterzeichnenden (z.B. Vollmacht: Vollmachtgeber/in
UND Bevollmaechtigte/r) auf eine Seite passen. Nur eine kleine, feste
Anzahl an Spalten wird erwartet (ueblich: 2) - deshalb einfaches Flexbox-
Layout statt einer generischen Grid-Loesung. */
.unterschriften {
display: flex;
justify-content: space-between;
gap: 10mm;
margin-top: 4mm;
}
.unterschrift-spalte {
flex: 1;
}
.unterschrift-linie {
border-top: 1px solid #111;
padding-top: 1mm;
}
.fusszeile {
margin-top: 6mm;
padding-top: 2mm;
border-top: 1px solid #ccc;
font-size: 9pt;
color: #333;
}
</style>
</head>
<body>{{ text }}</body>
<body>
<div class="absenderzeile">{{ absender }}</div>
<div class="empfaenger">{{ empfaenger }}</div>
<div class="spacer"></div>
<div class="brief-body">{{ text }}</div>
{% if unterschriften %}
<div class="unterschriften">
{% for u in unterschriften %}
<div class="unterschrift-spalte">
<div class="unterschrift-linie">{{ u.name }}</div>
<div>{{ u.label }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if fusszeile %}
<div class="fusszeile">{{ fusszeile }}</div>
{% endif %}
</body>
</html>
@@ -0,0 +1,74 @@
<!doctype html>
<html>
<head>
<style>
@page { size: A4; margin: 20mm; }
body {
margin: 0;
font-family: "DejaVu Sans", Arial, Helvetica, sans-serif;
font-size: 10.5pt;
line-height: 1.35;
color: #111;
}
.dokument-body { white-space: pre-wrap; }
.unterschriften {
display: flex;
justify-content: flex-start;
gap: 10mm;
margin-top: 8mm;
}
.unterschrift-spalte { width: 70mm; }
.unterschrift-linie {
border-top: 1px solid #111;
padding-top: 1mm;
}
.aerztliche-bestaetigung {
margin-top: 10mm;
font-size: 10pt;
}
.aerztliche-bestaetigung hr {
border: none;
border-top: 1px solid #111;
margin-bottom: 4mm;
}
.fusszeile {
margin-top: 6mm;
padding-top: 2mm;
border-top: 1px solid #ccc;
font-size: 9pt;
color: #333;
}
</style>
</head>
<body>
<div class="dokument-body">{{ text }}</div>
{% if unterschriften %}
<div class="unterschriften">
{% for u in unterschriften %}
<div class="unterschrift-spalte">
<div class="unterschrift-linie">{{ u.name }}</div>
<div>{{ u.label }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if aerztliche_bestaetigung %}
<div class="aerztliche-bestaetigung">
<hr>
<p><strong>Ärztliche Aufklärung/Bestätigung der Einwilligungsfähigkeit (optional)</strong></p>
<p>Herr/Frau _________________________ wurde von mir am _______________<br>
bezüglich der möglichen Folgen dieser Patientenverfügung aufgeklärt.<br>
Er/Sie war in vollem Umfang einwilligungsfähig.</p>
<p>Datum, Unterschrift Ärztin/Arzt: _________________________</p>
</div>
{% endif %}
{% if fusszeile %}
<div class="fusszeile">{{ fusszeile }}</div>
{% endif %}
</body>
</html>
@@ -0,0 +1,102 @@
"""Taeglicher Tagebuch-Check-in-Reminder per E-Mail (Tester-Feedback:
"Automatisierte Erinnerungen (Push-Notifications): 'Wie war der Tag mit Oma
heute?' [...] Damit das Tagebuch gefuellt wird").
Diese App hat bewusst keine Scheduler-/Cron-Infrastruktur (siehe CLAUDE.md:
der Demo-Reset laeuft ueber einen "lazy check" bei jedem Request, nicht ueber
einen eigenstaendigen Hintergrundprozess) - eine pro-Nutzer frei waehlbare
Uhrzeit laesst sich damit aber nicht abbilden, ein Nutzer koennte tagelang
keinen Request ausloesen, der den lazy check anstossen wuerde. Deshalb hier
bewusst die Ausnahme: ein einfacher Hintergrund-Thread mit Python-Bordmitteln
(kein neuer Dependency wie APScheduler/Celery), der alle paar Minuten prueft,
wer jetzt dran waere.
Gunicorn startet mehrere Worker-Prozesse - ohne Schutz wuerde jeder Worker
seinen eigenen Scheduler-Thread starten und Erinnerungen mehrfach verschicken.
Deshalb ein Postgres-Advisory-Lock (siehe auch katalog_watch.py fuer dieselbe
Problematik bei Multi-Worker-Race), diesmal ueber die GESAMTE Lebensdauer des
Threads gehalten (nicht nur fuer einen einzelnen Durchlauf): der Lock haengt
an einer dauerhaft offenen Connection, die genau so lange lebt wie der
Thread. Nur der Worker, der den Lock zuerst bekommt, betreibt den Scheduler;
alle anderen beenden ihren Thread sofort wieder."""
import datetime
import logging
import threading
import time
from sqlalchemy import text
from app.db import SessionLocal, engine
from app.email_util import send_email
from app.models import Nutzer
logger = logging.getLogger(__name__)
_ADVISORY_LOCK_ID = 738104562
_PRUEF_INTERVALL_SEKUNDEN = 300 # alle 5 Minuten pruefen, wer faellig ist
def starte_reminder_scheduler() -> None:
thread = threading.Thread(target=_scheduler_thread, name="reminder-scheduler", daemon=True)
thread.start()
def _scheduler_thread() -> None:
# Eigene, dauerhaft offene Connection nur fuer den Advisory-Lock - der
# Lock ist an die Connection gebunden und wird automatisch freigegeben,
# sobald sie schliesst oder der Prozess stirbt.
lock_connection = engine.connect()
got_lock = lock_connection.execute(
text("SELECT pg_try_advisory_lock(:id)"), {"id": _ADVISORY_LOCK_ID}
).scalar()
if not got_lock:
logger.info("Reminder-Scheduler laeuft schon in einem anderen Worker - dieser Thread beendet sich.")
lock_connection.close()
return
logger.info("Reminder-Scheduler gestartet (prueft alle %s Sekunden).", _PRUEF_INTERVALL_SEKUNDEN)
try:
while True:
try:
_sende_faellige_reminder()
except Exception:
logger.exception("Fehler im Reminder-Scheduler-Durchlauf")
time.sleep(_PRUEF_INTERVALL_SEKUNDEN)
finally:
lock_connection.close()
def _sende_faellige_reminder() -> None:
jetzt = datetime.datetime.now()
heute = jetzt.date()
db = SessionLocal()
try:
kandidaten = (
db.query(Nutzer)
.filter(Nutzer.reminder_aktiv.is_(True), Nutzer.reminder_uhrzeit.isnot(None))
.all()
)
for nutzer in kandidaten:
if not nutzer.email:
continue
if nutzer.reminder_zuletzt_gesendet == heute:
continue
if jetzt.time() < nutzer.reminder_uhrzeit:
continue
send_email(
nutzer.email,
"MeinPflegeFuchs – Wie war der Tag?",
(
f"Hallo {nutzer.name or nutzer.benutzername},\n\n"
"kurzer Check-in für heute: Wie war der Tag mit der von Ihnen gepflegten Person?\n"
"Schlaf, Stimmung, besondere Vorfälle, Medikamente – tragen Sie es direkt im "
"Pflegetagebuch in MeinPflegeFuchs ein, das dauert nur eine halbe Minute.\n\n"
"Diese Erinnerung lässt sich jederzeit in Ihren Kontoeinstellungen abschalten.\n\n"
"Erstellt mit MeinPflegeFuchs."
),
)
nutzer.reminder_zuletzt_gesendet = heute
db.commit()
finally:
db.close()
+124 -4
View File
@@ -4,17 +4,19 @@ import io
import json
import os
import re
import tempfile
import zipfile
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from app import backup as backup_modul
from app import changelog as changelog_logik
from app.archivierung import add_monate, archiviere_patient
from app.audit import log_admin_aktion
from app.config import settings
from app.db import get_db
from app.db import SessionLocal, get_db
from app.deps import require_admin
from app.einstellungen import abo_preise, berechne_abo_preis, get_setting, set_setting
from app.models import (
@@ -34,6 +36,7 @@ from app.rechnung_text import build_rechnung_text
from app.schemas import (
AboFreischaltenRequest,
AuditLogOut,
BackupEintragOut,
BegruendungRequest,
NutzerCreate,
NutzerOut,
@@ -92,11 +95,15 @@ def nutzer_anlegen(payload: NutzerCreate, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="Passwort muss mindestens 6 Zeichen lang sein")
if db.query(Nutzer).filter(Nutzer.benutzername == payload.benutzername.strip()).first():
raise HTTPException(status_code=409, detail="Benutzername bereits vergeben")
email = payload.email.strip().lower() or None
if email and db.query(Nutzer).filter(Nutzer.email == email).first():
raise HTTPException(status_code=409, detail="E-Mail-Adresse bereits vergeben")
nutzer = Nutzer(
benutzername=payload.benutzername.strip(),
passwort_hash=hash_password(payload.passwort),
name=payload.name,
email=email,
rolle=payload.rolle,
rg_name=payload.rg_name,
rg_strasse=payload.rg_strasse,
@@ -117,9 +124,15 @@ def nutzer_aktualisieren(uid: int, payload: NutzerUpdate, db: Session = Depends(
if nutzer is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
email = payload.email.strip().lower() or None
if email and email != nutzer.email:
if db.query(Nutzer).filter(Nutzer.email == email, Nutzer.id != uid).first():
raise HTTPException(status_code=409, detail="E-Mail-Adresse bereits vergeben")
if payload.passwort:
nutzer.passwort_hash = hash_password(payload.passwort)
nutzer.name = payload.name
nutzer.email = email
nutzer.rolle = payload.rolle
nutzer.aktiv = payload.aktiv
nutzer.rg_name = payload.rg_name
@@ -287,7 +300,7 @@ def dsgvo_export(
"exportiert_am": datetime.datetime.now().isoformat(),
"rechtsgrundlage": grund,
"hinweis": "Export gemäß Art. 15/20 DSGVO bzw. rechtlicher/anwaltlicher Anordnung. "
"Enthält alle in Pflegepilot gespeicherten Daten zu diesem Patienten inkl. Dokumenten.",
"Enthält alle in MeinPflegeFuchs gespeicherten Daten zu diesem Patienten inkl. Dokumenten.",
},
}
@@ -319,6 +332,113 @@ def dsgvo_export(
)
# ─── System-Backup (Betreiber-Absicherung, kein Betroffenenrecht) ────────────
@router.get("/api/admin/backups", response_model=list[BackupEintragOut])
def backup_liste():
return backup_modul.liste_backups()
@router.post("/api/admin/backups", response_model=BackupEintragOut, status_code=201)
def backup_erstellen(payload: BegruendungRequest, request: Request, db: Session = Depends(get_db)):
grund = _require_begruendung(payload)
admin = db.get(Nutzer, request.session["nutzer_id"])
try:
dateiname = backup_modul.erstelle_backup()
except backup_modul.BackupFehler as e:
raise HTTPException(status_code=500, detail=str(e)) from e
backup_modul.alte_backups_aufraeumen()
log_admin_aktion(db, admin, "backup_erstellt", "system", None, dateiname, grund)
db.commit()
eintrag = next((b for b in backup_modul.liste_backups() if b["dateiname"] == dateiname), None)
if eintrag is None:
raise HTTPException(status_code=500, detail="Backup erstellt, aber nicht auffindbar")
return eintrag
@router.post("/api/admin/backups/restore")
def backup_wiederherstellen(request: Request, archiv: UploadFile = File(...), begruendung: str = Form(...)):
"""Stellt Datenbank + Uploads aus einem hochgeladenen Backup-Archiv
wieder her (Betreiber-Wunsch: Wiederaufbau nach einem Totalausfall per
Einzel-Upload). Mit Abstand die destruktivste Aktion im ganzen Admin-
Bereich - siehe backup_modul.restore_backup() fuer die Sicherungen davor
(Sicherheitskopie vor jedem Restore, alles-oder-nichts-Transaktion).
Nutzt bewusst NICHT die per Depends(get_db) injizierte, request-gebundene
Session: restore_backup() ruft am Ende engine.dispose() auf (verwirft
alle gepoolten Verbindungen, damit folgende Requests garantiert frisch
verbinden) - eine Session, die vor diesem Aufruf bereits eine Verbindung
aus dem Pool gezogen hatte, waere danach in einem undefinierten Zustand.
Admin-Daten werden deshalb vor dem Restore in reine Variablen kopiert,
der Audit-Log-Eintrag hinterher ueber eine eigene, neue Session
geschrieben."""
grund = begruendung.strip()
if not grund:
raise HTTPException(status_code=400, detail="Begründung erforderlich")
if not (archiv.filename or "").endswith(".tar.gz"):
raise HTTPException(status_code=400, detail="Erwartet wird eine .tar.gz-Backup-Datei")
vorab_db = SessionLocal()
try:
admin_vorab = vorab_db.get(Nutzer, request.session["nutzer_id"])
admin_id, admin_name = admin_vorab.id, (admin_vorab.name or admin_vorab.benutzername)
finally:
vorab_db.close()
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp.write(archiv.file.read())
tmp_pfad = tmp.name
try:
sicherheitskopie = backup_modul.restore_backup(tmp_pfad)
except backup_modul.BackupFehler as e:
raise HTTPException(status_code=500, detail=str(e)) from e
finally:
os.remove(tmp_pfad)
nachher_db = SessionLocal()
try:
nachher_db.add(
AdminAuditLog(
admin_id=admin_id,
admin_name=admin_name,
aktion="backup_wiederhergestellt",
ziel_typ="system",
ziel_id=None,
ziel_bezeichnung=f"Sicherheitskopie vorher: {sicherheitskopie}",
begruendung=grund,
)
)
nachher_db.commit()
finally:
nachher_db.close()
return {"ok": True, "sicherheitskopie_vorher": sicherheitskopie}
@router.get("/api/admin/backups/{dateiname}")
def backup_herunterladen(dateiname: str, request: Request, db: Session = Depends(get_db)):
pfad = backup_modul.backup_pfad(dateiname)
if pfad is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
admin = db.get(Nutzer, request.session["nutzer_id"])
log_admin_aktion(db, admin, "backup_heruntergeladen", "system", None, dateiname, "Download durch Admin")
db.commit()
return FileResponse(pfad, media_type="application/gzip", filename=dateiname)
@router.delete("/api/admin/backups/{dateiname}")
def backup_loeschen(dateiname: str):
pfad = backup_modul.backup_pfad(dateiname)
if pfad is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
os.remove(pfad)
return {"ok": True}
# ─── Backend-Einstellungen (generischer Key-Value-Store) ─────────────────────
@router.get("/api/admin/einstellungen")
+49 -6
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Query, Session
from app.db import get_db
@@ -9,6 +11,19 @@ from app.schemas import AufgabeCreate, AufgabeOut
router = APIRouter(prefix="/api/aufgaben", tags=["aufgaben"], dependencies=[Depends(require_active_access)])
def _owned_aufgabe_or_404(db: Session, user: Nutzer, aid: int) -> Aufgabe:
aufgabe = db.get(Aufgabe, aid)
if aufgabe is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
if user.rolle != "admin" and not (aufgabe.patient_id and aufgabe.patient and aufgabe.patient.nutzer_id == user.id):
raise HTTPException(status_code=404, detail="Nicht gefunden")
return aufgabe
def _ics_escape(text: str) -> str:
return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
def _mit_patientenname(db: Session, aufgaben: list[Aufgabe]) -> list[AufgabeOut]:
ergebnis = []
for a in aufgaben:
@@ -55,11 +70,39 @@ def anlegen(
@router.post("/{aid}/erledigt")
def erledigt(aid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_login)):
aufgabe = db.get(Aufgabe, aid)
if aufgabe is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
if user.rolle != "admin" and not (aufgabe.patient_id and aufgabe.patient and aufgabe.patient.nutzer_id == user.id):
raise HTTPException(status_code=404, detail="Nicht gefunden")
aufgabe = _owned_aufgabe_or_404(db, user, aid)
aufgabe.erledigt = True
db.commit()
return {"ok": True}
@router.get("/{aid}/ics")
def ics_export(aid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_login)):
aufgabe = _owned_aufgabe_or_404(db, user, aid)
if aufgabe.faellig_am is None:
raise HTTPException(status_code=400, detail="Aufgabe hat kein Fälligkeitsdatum")
dtstart = aufgabe.faellig_am.strftime("%Y%m%d")
dtend = (aufgabe.faellig_am + datetime.timedelta(days=1)).strftime("%Y%m%d")
dtstamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
zeilen = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//MeinPflegeFuchs//Aufgaben//DE",
"BEGIN:VEVENT",
f"UID:aufgabe-{aufgabe.id}@meinpflegefuchs.de",
f"DTSTAMP:{dtstamp}",
f"DTSTART;VALUE=DATE:{dtstart}",
f"DTEND;VALUE=DATE:{dtend}",
f"SUMMARY:{_ics_escape(aufgabe.titel)}",
]
if aufgabe.beschreibung:
zeilen.append(f"DESCRIPTION:{_ics_escape(aufgabe.beschreibung)}")
zeilen += ["END:VEVENT", "END:VCALENDAR"]
return Response(
content="\r\n".join(zeilen) + "\r\n",
media_type="text/calendar; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="aufgabe-{aufgabe.id}.ics"'},
)
+120 -7
View File
@@ -1,18 +1,34 @@
from datetime import datetime, timezone
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app import changelog as changelog_logik
from app.config import settings
from app.db import get_db
from app.deps import get_current_user, hat_bezahlten_zugang
from app.deps import get_current_user, hat_bezahlten_zugang, require_login
from app.email_util import send_email
from app.models import Nutzer
from app.ratelimit import is_rate_limited
from app.schemas import AuthResponse, LoginRequest, MeResponse, RegistrierenRequest
from app.schemas import (
AuthResponse,
LoginRequest,
MeResponse,
PasswortVergessenRequest,
PasswortZuruecksetzenRequest,
ReminderEinstellungenUpdate,
RegistrierenRequest,
)
from app.security import client_ip, hash_password, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
_EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
RESET_TOKEN_GUELTIGKEIT = timedelta(hours=1)
def _stempel_login(db: Session, user: Nutzer, request: Request) -> None:
user.letzter_login = datetime.now(timezone.utc)
@@ -44,28 +60,45 @@ def registrieren(payload: RegistrierenRequest, request: Request, db: Session = D
raise HTTPException(status_code=429, detail="Zu viele Versuche, bitte später erneut versuchen.")
benutzername = payload.benutzername.strip()
email = payload.email.strip().lower()
if not benutzername or not payload.passwort or not payload.name.strip():
raise HTTPException(status_code=400, detail="Benutzername, Passwort und Name erforderlich")
if len(payload.passwort) < 6:
raise HTTPException(status_code=400, detail="Passwort muss mindestens 6 Zeichen lang sein")
if not _EMAIL_REGEX.match(email):
raise HTTPException(status_code=400, detail="Bitte eine gültige E-Mail-Adresse angeben")
if not (payload.rg_name and payload.rg_strasse and payload.rg_plz and payload.rg_ort):
raise HTTPException(
status_code=400, detail="Rechnungsanschrift (Name, Straße, PLZ, Ort) ist erforderlich"
)
if not (payload.zustimmung_sofortbeginn and payload.zustimmung_widerrufsverzicht):
raise HTTPException(
status_code=400,
detail="Bitte bestätigen Sie beide Kontrollkästchen zum Widerrufsrecht (§ 356 Abs. 4 BGB)",
)
if db.query(Nutzer).filter(Nutzer.benutzername == benutzername).first():
raise HTTPException(status_code=409, detail="Benutzername bereits vergeben")
if db.query(Nutzer).filter(Nutzer.email == email).first():
raise HTTPException(status_code=409, detail="E-Mail-Adresse bereits vergeben")
user = Nutzer(
benutzername=benutzername,
passwort_hash=hash_password(payload.passwort),
name=payload.name.strip(),
email=email,
rolle="nutzer",
rg_name=payload.rg_name,
rg_strasse=payload.rg_strasse,
rg_plz=payload.rg_plz,
rg_ort=payload.rg_ort,
selbst_registriert=True,
# Nachweis nach § 356 Abs. 4 BGB, dass der Nutzer der vorzeitigen
# Ausfuehrung ausdruecklich zugestimmt und den damit verbundenen
# Verlust seines Widerrufsrechts zur Kenntnis genommen hat - beide
# Kontrollkaestchen sind serverseitig (nicht nur im Frontend)
# Pflichtfelder, siehe Pruefung oben.
widerrufsverzicht_bestaetigt_am=datetime.now(timezone.utc),
# Neues Konto startet "aufgeholt", damit es keine historischen
# Changelog-Eintraege sehen muss, die es nie erlebt hat.
letzte_gesehene_version=changelog_logik.app_version(db),
@@ -85,18 +118,98 @@ def logout(request: Request):
return {"ok": True}
@router.get("/me", response_model=MeResponse)
def me(user: Nutzer | None = Depends(get_current_user), db: Session = Depends(get_db)):
if user is None:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
@router.post("/passwort-vergessen")
def passwort_vergessen(payload: PasswortVergessenRequest, request: Request, db: Session = Depends(get_db)):
"""Nimmt eine E-Mail-Adresse entgegen und verschickt bei Treffer einen
zeitlich begrenzten Reset-Link. Antwortet IMMER gleich, unabhaengig davon
ob die Adresse einem Konto zugeordnet ist - sonst liesse sich am
Antwortverhalten erkennen, welche E-Mail-Adressen registriert sind
(User-Enumeration)."""
if is_rate_limited("passwort-vergessen", client_ip(request), max_versuche=5, fenster_sekunden=600):
raise HTTPException(status_code=429, detail="Zu viele Versuche, bitte später erneut versuchen.")
email = payload.email.strip().lower()
user = db.query(Nutzer).filter(Nutzer.email == email, Nutzer.aktiv.is_(True)).first()
if user:
token = secrets.token_urlsafe(32)
user.reset_token_hash = hashlib.sha256(token.encode()).hexdigest()
user.reset_token_laeuft_ab = datetime.now(timezone.utc) + RESET_TOKEN_GUELTIGKEIT
db.commit()
link = f"{settings.web_origins[0]}/passwort-zuruecksetzen?token={token}"
send_email(
user.email,
"MeinPflegeFuchs – Passwort zurücksetzen",
f"Hallo {user.name or user.benutzername},\n\n"
"zum Zurücksetzen Ihres Passworts klicken Sie bitte auf folgenden Link "
f"(eine Stunde gültig):\n\n{link}\n\n"
"Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren "
"– Ihr Passwort bleibt dann unverändert.",
)
return {"ok": True}
@router.post("/passwort-zuruecksetzen")
def passwort_zuruecksetzen(payload: PasswortZuruecksetzenRequest, db: Session = Depends(get_db)):
if len(payload.neues_passwort) < 6:
raise HTTPException(status_code=400, detail="Passwort muss mindestens 6 Zeichen lang sein")
token_hash = hashlib.sha256(payload.token.encode()).hexdigest()
user = db.query(Nutzer).filter(Nutzer.reset_token_hash == token_hash).first()
if (
user is None
or user.reset_token_laeuft_ab is None
or user.reset_token_laeuft_ab < datetime.now(timezone.utc)
):
raise HTTPException(status_code=400, detail="Der Link ist ungültig oder abgelaufen")
user.passwort_hash = hash_password(payload.neues_passwort)
user.reset_token_hash = None
user.reset_token_laeuft_ab = None
db.commit()
return {"ok": True}
def _me_response(user: Nutzer, db: Session) -> MeResponse:
return MeResponse(
id=user.id,
benutzername=user.benutzername,
name=user.name,
email=user.email,
rolle=user.rolle,
ist_demo=user.ist_demo,
selbst_registriert=user.selbst_registriert,
app_version=changelog_logik.app_version(db),
changelog_ungesehen=changelog_logik.ungesehen(db, user.letzte_gesehene_version),
schreibzugriff_gesperrt=user.selbst_registriert and not hat_bezahlten_zugang(user),
rg_name=user.rg_name,
rg_strasse=user.rg_strasse,
rg_plz=user.rg_plz,
rg_ort=user.rg_ort,
reminder_aktiv=user.reminder_aktiv,
reminder_uhrzeit=user.reminder_uhrzeit,
)
@router.get("/me", response_model=MeResponse)
def me(user: Nutzer | None = Depends(get_current_user), db: Session = Depends(get_db)):
if user is None:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
return _me_response(user, db)
@router.patch("/reminder-einstellungen", response_model=MeResponse)
def reminder_einstellungen_setzen(
payload: ReminderEinstellungenUpdate,
user: Nutzer = Depends(require_login),
db: Session = Depends(get_db),
):
"""Tagebuch-Erinnerungs-Mail an/aus + Uhrzeit (Tester-Feedback: "E-Mail
Push Service einstellbar ... bezueglich ein und ausschalten und der
Uhrzeit"). Der tatsaechliche Versand laeuft ueber reminder_scheduler.py."""
if payload.reminder_aktiv and payload.reminder_uhrzeit is None:
raise HTTPException(status_code=400, detail="Bitte eine Uhrzeit für die Erinnerung angeben")
user.reminder_aktiv = payload.reminder_aktiv
user.reminder_uhrzeit = payload.reminder_uhrzeit
db.commit()
db.refresh(user)
return _me_response(user, db)
+236 -14
View File
@@ -10,6 +10,8 @@ from app.briefe_text import (
_rv_adresse,
build_adressaenderung_text,
build_musterbrief_text,
build_patientenverfuegung_text,
build_vollmacht_text,
build_widerspruch_text,
)
from app.config import settings
@@ -17,7 +19,28 @@ from app.db import get_db
from app.deps import get_owned_patient, require_active_access, require_login, require_paid_access
from app.models import Antrag, Nutzer, Patient
from app.pdf import render_pdf
from app.schemas import AdressaenderungOut, AdressaenderungRequest, AntragOut, MusterbriefRequest, WiderspruchRequest
from app.schemas import (
AdressaenderungOut,
AdressaenderungRequest,
AntragOut,
ChecklistePunktOut,
MusterbriefRequest,
PatientenverfuegungRequest,
PatientenverfuegungTextbausteineOut,
VollmachtRequest,
WiderspruchRequest,
)
from app.static_data.antrag_checkliste import checkliste_fuer
from app.static_data.patientenverfuegung_textbausteine import (
MASSNAHMEN,
ORGANSPENDE_KONFLIKT_OPTIONEN,
ORGANSPENDE_OPTIONEN,
ORT_OPTIONEN,
SITUATIONEN,
VERTRAUENSPERSON_OPTIONEN,
WIEDERBELEBUNG_A_OPTIONEN,
WIEDERBELEBUNG_B_OPTIONEN,
)
from app.static_data.widerspruch import BEWEISMITTEL_OPTIONEN, WGRUENDE
router = APIRouter(tags=["briefe"], dependencies=[Depends(require_active_access)])
@@ -39,11 +62,47 @@ def _pdf_speichern(patient_id: int, dateiname: str, pdf_bytes: bytes) -> str:
return f"briefe/{patient_id}/{safe_name}"
def _hat_vollmacht(db: Session, patient_id: int) -> bool:
"""True, sobald fuer den Patienten irgendwann eine Vollmacht erstellt
wurde ("hinterlegt ist" - Tester-Feedback "Vollmacht 2"). Steuert, ob
nachfolgend generierte Briefe automatisch vom Bevollmaechtigten statt vom
Patienten unterschrieben werden (siehe _unterschrift_zeilen() in
briefe_text.py)."""
return (
db.query(Antrag)
.filter(Antrag.patient_id == patient_id, Antrag.typ == "vollmacht")
.first()
is not None
)
def _unterzeichner(db: Session, user: Nutzer, patient_id: int) -> str | None:
if not _hat_vollmacht(db, patient_id):
return None
return user.name or user.benutzername
def _absender_zeile(*teile: str | None) -> str:
"""Kompakte einzeilige Absenderzeile fuers DIN-5008-Kuvertfenster (siehe
pdf_templates/brief.html: .absenderzeile ist nowrap/overflow:hidden - passt
nur eine kurze Zeile, keine Kontaktdaten wie Telefon/E-Mail)."""
return ", ".join(t for t in teile if t)
@router.get("/api/widerspruch/gruende")
def widerspruch_gruende(_=Depends(require_login)):
return {"gruende": WGRUENDE, "beweismittel_optionen": BEWEISMITTEL_OPTIONEN}
@router.get("/api/antraege/checkliste", response_model=list[ChecklistePunktOut])
def antrag_checkliste(typ: str, _=Depends(require_login)):
"""Unverbindliche Orientierung, welche Unterlagen fuer eine Antragsart
ueblicherweise angefordert werden (siehe static_data/antrag_checkliste.py
fuer die Recherche-Quellen und den Vorbehalt zur Einzelfallabhaengigkeit).
Rein informativ - kein Gate fuer das Erstellen eines Antrags/Briefs."""
return checkliste_fuer(typ)
@router.post("/api/patienten/{pid}/widerspruch", response_model=AntragOut, status_code=201)
def widerspruch_erstellen(
pid: int,
@@ -57,18 +116,19 @@ def widerspruch_erstellen(
text = build_widerspruch_text(
name=payload.name,
adresse=payload.adresse,
geburtsdatum=payload.geburtsdatum,
versichertennummer=payload.versichertennummer,
krankenkasse=payload.krankenkasse,
bescheiddatum=payload.bescheiddatum,
aktenzeichen=payload.aktenzeichen,
ist_grad=payload.ist_grad,
soll_grad=payload.soll_grad,
grund_ids=payload.grund_ids,
beweismittel=payload.beweismittel,
unterzeichner=_unterzeichner(db, user, pid),
)
pdf_bytes = render_pdf("brief.html", text=text)
absender = _absender_zeile(payload.name, payload.adresse)
empfaenger = f"{payload.krankenkasse or '[Krankenkasse]'}\n{_kk_adresse(payload.krankenkasse)}"
pdf_bytes = render_pdf("brief.html", text=text, absender=absender, empfaenger=empfaenger)
dateiname = f"Widerspruch_{patient.nachname}_{payload.bescheiddatum.isoformat()}.pdf"
pdf_pfad = _pdf_speichern(pid, dateiname, pdf_bytes)
@@ -97,18 +157,24 @@ def musterbrief_erstellen(
):
patient = _owned_or_404(db, user, pid)
# Patient hat meist keine eigene E-Mail-Adresse hinterlegt - dann faellt
# der Absender auf die E-Mail des (bevollmaechtigten) Nutzers zurueck,
# statt die Zeile leer zu lassen (Kundenfeedback 2026-07-27).
absender_email = payload.absender_email or patient.email or user.email
text = build_musterbrief_text(
absender_name=payload.absender_name,
absender_strasse=payload.absender_strasse,
absender_plz_ort=payload.absender_plz_ort,
absender_telefon=payload.absender_telefon,
krankenkasse=payload.krankenkasse,
absender_email=absender_email,
typ=payload.typ,
versicherter_name=payload.versicherter_name,
geburtsdatum=payload.geburtsdatum,
versichertennummer=payload.versichertennummer,
unterzeichner=_unterzeichner(db, user, pid),
)
pdf_bytes = render_pdf("brief.html", text=text)
absender = _absender_zeile(payload.absender_name, payload.absender_strasse, payload.absender_plz_ort)
empfaenger = f"{payload.krankenkasse or '[Krankenkasse]'}\n{_kk_adresse(payload.krankenkasse)}"
pdf_bytes = render_pdf("brief.html", text=text, absender=absender, empfaenger=empfaenger)
dateiname = f"Musterbrief_{patient.nachname}_{payload.typ}.pdf"
pdf_pfad = _pdf_speichern(pid, dateiname, pdf_bytes)
@@ -142,22 +208,30 @@ def adressaenderung_erstellen(
alte_strasse, alte_plz, alte_ort = patient.strasse, patient.plz, patient.ort
versicherter_name = f"{patient.vorname} {patient.nachname}"
# Patient hat meist keine eigene E-Mail-Adresse hinterlegt - dann faellt
# der Absender auf die E-Mail des (bevollmaechtigten) Nutzers zurueck.
absender_email = payload.absender_email or patient.email or user.email
unterzeichner = _unterzeichner(db, user, pid)
absender = _absender_zeile(versicherter_name, payload.neue_strasse, f"{payload.neue_plz} {payload.neue_ort}")
kk_antrag = None
if payload.krankenkasse_benachrichtigen:
kk_name = payload.krankenkasse or patient.krankenkasse or "[Krankenkasse]"
text = build_adressaenderung_text(
versicherter_name=versicherter_name,
geburtsdatum=patient.geburtsdatum,
absender_telefon=payload.absender_telefon,
empfaenger_name=payload.krankenkasse or patient.krankenkasse or "[Krankenkasse]",
empfaenger_adresse=_kk_adresse(payload.krankenkasse or patient.krankenkasse),
absender_email=absender_email,
kennzeichen_label="Versichertennummer",
kennzeichen_wert=patient.versichertennummer,
alte_strasse=alte_strasse, alte_plz=alte_plz, alte_ort=alte_ort,
neue_strasse=payload.neue_strasse, neue_plz=payload.neue_plz, neue_ort=payload.neue_ort,
gueltig_ab=payload.gueltig_ab,
unterzeichner=unterzeichner,
)
pdf_bytes = render_pdf("brief.html", text=text)
empfaenger = f"{kk_name}\n{_kk_adresse(payload.krankenkasse or patient.krankenkasse)}"
pdf_bytes = render_pdf("brief.html", text=text, absender=absender, empfaenger=empfaenger)
pdf_pfad = _pdf_speichern(pid, f"Ummeldung_Krankenkasse_{patient.nachname}.pdf", pdf_bytes)
kk_antrag = Antrag(patient_id=pid, typ="adressaenderung", status="offen", brief_text=text, pdf_pfad=pdf_pfad)
db.add(kk_antrag)
@@ -169,15 +243,16 @@ def adressaenderung_erstellen(
versicherter_name=versicherter_name,
geburtsdatum=patient.geburtsdatum,
absender_telefon=payload.absender_telefon,
empfaenger_name=traeger,
empfaenger_adresse=_rv_adresse(traeger),
absender_email=absender_email,
kennzeichen_label="Versicherungsnummer",
kennzeichen_wert=patient.rentenversicherungsnummer,
alte_strasse=alte_strasse, alte_plz=alte_plz, alte_ort=alte_ort,
neue_strasse=payload.neue_strasse, neue_plz=payload.neue_plz, neue_ort=payload.neue_ort,
gueltig_ab=payload.gueltig_ab,
unterzeichner=unterzeichner,
)
pdf_bytes = render_pdf("brief.html", text=text)
empfaenger = f"{traeger}\n{_rv_adresse(traeger)}"
pdf_bytes = render_pdf("brief.html", text=text, absender=absender, empfaenger=empfaenger)
pdf_pfad = _pdf_speichern(pid, f"Ummeldung_Rentenversicherung_{patient.nachname}.pdf", pdf_bytes)
rv_antrag = Antrag(patient_id=pid, typ="adressaenderung", status="offen", brief_text=text, pdf_pfad=pdf_pfad)
db.add(rv_antrag)
@@ -192,6 +267,153 @@ def adressaenderung_erstellen(
return AdressaenderungOut(patient=patient, krankenkasse_brief=kk_antrag, rentenversicherung_brief=rv_antrag)
@router.post("/api/patienten/{pid}/vollmacht", response_model=AntragOut, status_code=201)
def vollmacht_erstellen(
pid: int,
payload: VollmachtRequest,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_paid_access),
):
"""Vollmacht-Generator (Tester-Feedback): erstellt eine Vollmacht gemaess
§ 13 SGB X, die sowohl vom Patienten als auch vom Bevollmaechtigten
(Nutzer) unterschrieben wird. Sobald mindestens eine Vollmacht fuer den
Patienten existiert, unterschreiben nachfolgend generierte Widersprueche/
Musterbriefe/Ummeldungen automatisch "in Vollmacht" durch den Nutzer
(siehe _unterzeichner())."""
patient = _owned_or_404(db, user, pid)
if not payload.bevollmaechtigter_name.strip():
raise HTTPException(status_code=400, detail="Name des Bevollmächtigten ist erforderlich")
patient_name = f"{patient.vorname} {patient.nachname}"
krankenkasse = payload.krankenkasse or patient.krankenkasse
text = build_vollmacht_text(
patient_name=patient_name,
geburtsdatum=patient.geburtsdatum,
versichertennummer=patient.versichertennummer,
bevollmaechtigter_name=payload.bevollmaechtigter_name,
bevollmaechtigter_adresse=(
f"{payload.bevollmaechtigter_strasse}, {payload.bevollmaechtigter_plz_ort}".strip(", ")
or "[Anschrift bitte ergänzen]"
),
ort=payload.ort or patient.ort or "",
)
absender = _absender_zeile(patient_name, patient.strasse, f"{patient.plz or ''} {patient.ort or ''}".strip())
empfaenger = f"{krankenkasse or '[Krankenkasse]'}\n{_kk_adresse(krankenkasse)}"
# Zwei Unterschriften nebeneinander statt untereinander (Kundenfeedback),
# damit die Vollmacht auf eine Seite passt - als echtes Flexbox-Layout im
# Template gerendert statt als Text mit Leerzeichen ausgerichtet, da die
# DIN-5008-Vorlage eine proportionale Schrift (DejaVu Sans) nutzt.
unterschriften = [
{"name": patient_name, "label": "Vollmachtgeber/in"},
{"name": payload.bevollmaechtigter_name, "label": "Bevollmächtigte/r (§ 13 SGB X)"},
]
pdf_bytes = render_pdf(
"brief.html",
text=text,
absender=absender,
empfaenger=empfaenger,
unterschriften=unterschriften,
fusszeile="Erstellt mit MeinPflegeFuchs.",
)
dateiname = f"Vollmacht_{patient.nachname}.pdf"
pdf_pfad = _pdf_speichern(pid, dateiname, pdf_bytes)
antrag = Antrag(patient_id=pid, typ="vollmacht", status="offen", brief_text=text, pdf_pfad=pdf_pfad)
db.add(antrag)
db.commit()
db.refresh(antrag)
return antrag
@router.get("/api/patientenverfuegung/textbausteine", response_model=PatientenverfuegungTextbausteineOut)
def patientenverfuegung_textbausteine(_=Depends(require_login)):
"""Optionen fuers Interview-Formular - Formulierungen stammen aus der
BMJ-Broschuere "Patientenverfuegung" (siehe static_data/
patientenverfuegung_textbausteine.py fuer Quelle und Begruendung)."""
return {
"situationen": SITUATIONEN,
"massnahmen": MASSNAHMEN,
"wiederbelebung_a_optionen": WIEDERBELEBUNG_A_OPTIONEN,
"wiederbelebung_b_optionen": WIEDERBELEBUNG_B_OPTIONEN,
"ort_optionen": ORT_OPTIONEN,
"vertrauensperson_optionen": VERTRAUENSPERSON_OPTIONEN,
"organspende_optionen": ORGANSPENDE_OPTIONEN,
"organspende_konflikt_optionen": ORGANSPENDE_KONFLIKT_OPTIONEN,
}
@router.post("/api/patienten/{pid}/patientenverfuegung", response_model=AntragOut, status_code=201)
def patientenverfuegung_erstellen(
pid: int,
payload: PatientenverfuegungRequest,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_paid_access),
):
"""Patientenverfuegung-Generator (Betreiber-Anfrage): im Gegensatz zu den
anderen Brief-Generatoren ein Interview ueber mehrere Schritte statt eines
einzelnen Formulars, weil hier vor der PDF-Erzeugung inhaltlich einiges
abgeklaert werden muss (Situationen, medizinische Massnahmen je Situation,
Organspende, Vorsorgevollmacht-Bezug etc.) - siehe PatientenverfuegungRequest.
Anders als Widerspruch/Musterbrief/Ummeldung/Vollmacht KEIN Brief an einen
Empfaenger (kein Fensterkuvert-Layout, kein Unterzeichner-Wechsel bei
hinterlegter Vollmacht - eine Patientenverfuegung drueckt zwingend den
eigenen Willen der betroffenen Person aus, siehe build_patientenverfuegung_text())."""
patient = _owned_or_404(db, user, pid)
if not payload.situationen and not (payload.situation_eigene or "").strip():
raise HTTPException(status_code=400, detail="Bitte mindestens eine Situation angeben")
patient_name = f"{patient.vorname} {patient.nachname}"
wohnort = _absender_zeile(patient.strasse, f"{patient.plz or ''} {patient.ort or ''}".strip())
text = build_patientenverfuegung_text(
patient_name=patient_name,
geburtsdatum=patient.geburtsdatum,
wohnort=wohnort,
situationen=payload.situationen,
situation_eigene=payload.situation_eigene,
massnahmen=payload.massnahmen,
schmerzbehandlung_zusatz=payload.schmerzbehandlung_zusatz,
wiederbelebung_a=payload.wiederbelebung_a,
wiederbelebung_b=payload.wiederbelebung_b,
ort=payload.ort,
beistand_personen=payload.beistand_personen,
beistand_kirche=payload.beistand_kirche,
hospizlicher_beistand=payload.hospizlicher_beistand,
schweigepflicht_personen=payload.schweigepflicht_personen,
vertrauensperson=payload.vertrauensperson,
vertrauensperson_andere_name=payload.vertrauensperson_andere_name,
vollmacht_name=payload.vollmacht_name,
betreuer_name=payload.betreuer_name,
organspende=payload.organspende,
organspende_konflikt=payload.organspende_konflikt,
aufklaerungsverzicht=payload.aufklaerungsverzicht,
informiert_bei=payload.informiert_bei,
beraten_durch=payload.beraten_durch,
ausstellungsort=payload.ausstellungsort or patient.ort or "",
)
unterschriften = [{"name": patient_name, "label": "Unterschrift"}]
pdf_bytes = render_pdf(
"patientenverfuegung.html",
text=text,
unterschriften=unterschriften,
aerztliche_bestaetigung=payload.aerztliche_bestaetigung_freifeld,
fusszeile=(
"Formulierungen basieren auf den Textbausteinen der Broschüre „Patientenverfügung“ des "
"Bundesministeriums der Justiz (bmj.de, Stand Januar 2023). Diese Patientenverfügung ersetzt "
"keine rechtliche oder ärztliche Beratung. Erstellt mit MeinPflegeFuchs."
),
)
dateiname = f"Patientenverfuegung_{patient.nachname}.pdf"
pdf_pfad = _pdf_speichern(pid, dateiname, pdf_bytes)
antrag = Antrag(patient_id=pid, typ="patientenverfuegung", status="offen", brief_text=text, pdf_pfad=pdf_pfad)
db.add(antrag)
db.commit()
db.refresh(antrag)
return antrag
@router.get("/api/patienten/{pid}/antraege/{aid}/pdf")
def antrag_pdf(aid: int, patient: Patient = Depends(get_owned_patient), db: Session = Depends(get_db)):
antrag = db.query(Antrag).filter(Antrag.id == aid, Antrag.patient_id == patient.id).first()
@@ -1,3 +1,5 @@
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Query, Session
@@ -10,6 +12,14 @@ router = APIRouter(
prefix="/api/dashboard", tags=["dashboard"], dependencies=[Depends(require_active_access)]
)
# Deckelt, wie viele Patienten-Kacheln bzw. Antrags-Gruppen auf dem
# Dashboard erscheinen - bewusst klein gehalten (Betreiber-Feedback: das
# Dashboard soll als Einstiegspunkt mehr Information bieten, aber "nicht zu
# voll werden"). Wer mehr Patienten/Antraege hat, findet den Rest ueber
# /patienten bzw. die Patientenakte selbst.
MAX_PATIENTEN_KACHELN = 6
MAX_OFFENE_ANTRAEGE_GRUPPEN = 5
@router.get("", response_model=DashboardOut)
def dashboard(
@@ -25,13 +35,66 @@ def dashboard(
offene_antraege_q = offene_antraege_q.filter(Antrag.patient_id.in_(patient_ids))
offene_aufgaben_q = offene_aufgaben_q.filter(Aufgabe.patient_id.in_(patient_ids))
letzte_patienten = (
patienten_query.order_by(Patient.geaendert_am.desc()).limit(5).all()
offene_antraege_alle = offene_antraege_q.order_by(Antrag.erstellt_am).all()
offene_aufgaben_alle = offene_aufgaben_q.all()
antraege_je_patient: dict[int, list[Antrag]] = defaultdict(list)
for a in offene_antraege_alle:
antraege_je_patient[a.patient_id].append(a)
aufgaben_anzahl_je_patient: dict[int, int] = defaultdict(int)
for auf in offene_aufgaben_alle:
if auf.patient_id is not None:
aufgaben_anzahl_je_patient[auf.patient_id] += 1
# Patienten-Kacheln: die zuletzt bearbeiteten Patienten, gebuendelt mit
# ihren offenen Antraegen/Aufgaben - ersetzt die bisherige schlichte
# Namensliste "Zuletzt bearbeitete Patienten" durch mehr Information auf
# einen Blick (Tester-/Betreiber-Feedback: Dashboard soll als Einstieg
# mehr bieten).
kachel_patienten = (
patienten_query.order_by(Patient.geaendert_am.desc()).limit(MAX_PATIENTEN_KACHELN).all()
)
patienten_uebersicht = [
{
"id": p.id,
"vorname": p.vorname,
"nachname": p.nachname,
"pflegegrad": p.pflegegrad,
"patient_status": p.patient_status,
"offene_antraege": len(antraege_je_patient.get(p.id, [])),
"offene_aufgaben": aufgaben_anzahl_je_patient.get(p.id, 0),
}
for p in kachel_patienten
]
# Offene Antraege nach Patient sortiert (Tester-/Betreiber-Feedback: die
# reine Zahl auf der Kachel oben war nicht klickbar/informativ genug) -
# je Patient nur der am laengsten offene Antrag als Vorschau, damit die
# Liste kompakt bleibt statt jeden einzelnen Antrag aufzuzaehlen.
# Sortiert danach, wessen aeltester offener Antrag am laengsten wartet.
gruppen = sorted(antraege_je_patient.items(), key=lambda kv: kv[1][0].erstellt_am)
patienten_nach_id = {p.id: p for p in patienten_query.filter(Patient.id.in_(antraege_je_patient.keys())).all()} if antraege_je_patient else {}
offene_antraege_nach_patient = []
for pid, antraege in gruppen[:MAX_OFFENE_ANTRAEGE_GRUPPEN]:
patient = patienten_nach_id.get(pid)
if patient is None:
continue
aeltester = antraege[0]
offene_antraege_nach_patient.append(
{
"patient_id": pid,
"patient_name": f"{patient.vorname} {patient.nachname}",
"anzahl": len(antraege),
"naechster_typ": aeltester.typ,
"naechster_status": aeltester.status,
}
)
return DashboardOut(
patienten_anzahl=len(patient_ids),
offene_antraege=offene_antraege_q.count(),
offene_aufgaben=offene_aufgaben_q.count(),
letzte_patienten=letzte_patienten,
offene_antraege=len(offene_antraege_alle),
offene_aufgaben=len(offene_aufgaben_alle),
patienten_uebersicht=patienten_uebersicht,
offene_antraege_nach_patient=offene_antraege_nach_patient,
)
@@ -1,3 +1,4 @@
import datetime
import os
import re
import time
@@ -6,15 +7,28 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app import dokument_scan
from app.config import settings
from app.db import get_db
from app.deps import get_owned_patient, require_active_access, require_login
from app.models import Dokument, Nutzer, Patient
from app.schemas import DokumentOut
from app.schemas import DokumentOut, DokumentTypOut
from app.static_data.dokument_typen import DOKUMENT_TYPEN
router = APIRouter(tags=["dokumente"], dependencies=[Depends(require_active_access)])
ERLAUBTE_ENDUNGEN = {".pdf", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".docx", ".txt", ".eml"}
ERLAUBTE_SCAN_ENDUNGEN = {".jpg", ".jpeg", ".png", ".webp"}
# HEIC bewusst nicht erlaubt: weder OpenCV noch Pillow koennen es ohne
# Zusatzbibliothek dekodieren. Unkritisch fuer den Kamera-Direktaufruf-Flow
# (iOS liefert bei "Foto aufnehmen" ueber ein Web-<input capture> immer JPEG,
# unabhaengig von der Kamera-Einstellung des Geraets) - relevant nur, falls
# jemand ein bestehendes HEIC-Foto aus der Mediathek waehlt.
@router.get("/api/dokumente/typen", response_model=list[DokumentTypOut])
def dokument_typen():
return DOKUMENT_TYPEN
@router.get("/api/patienten/{pid}/dokumente", response_model=list[DokumentOut])
@@ -64,6 +78,63 @@ def hochladen(
return dokument
@router.post("/api/patienten/{pid}/dokumente/scan", response_model=DokumentOut, status_code=201)
def scan_hochladen(
dateien: list[UploadFile] = File(...),
typ: str = Form("sonstiges"),
bezeichnung: str = Form(""),
datum: str | None = Form(None),
notizen: str = Form(""),
patient: Patient = Depends(get_owned_patient),
db: Session = Depends(get_db),
):
"""Dokumenten-Scan (Betreiber-Feedback: Zielgruppe ist zu ca. 80%
Tablet-/Handy-Nutzer/innen ohne eigenen Scanner) - eine oder mehrere per
Kamera aufgenommene Fotos werden serverseitig entzerrt (siehe
app/dokument_scan.py fuer die Kantenerkennung + Begruendung, warum das
serverseitig statt clientseitig laeuft) und zu einem gemeinsamen,
mehrseitigen PDF zusammengefasst - eine Scan-Session kann mehrere Seiten
eines Dokuments sein (z.B. ein mehrseitiger Bescheid)."""
if not dateien:
raise HTTPException(status_code=400, detail="Mindestens ein Foto erforderlich")
for datei in dateien:
ext = os.path.splitext(datei.filename or "")[1].lower()
if ext not in ERLAUBTE_SCAN_ENDUNGEN:
raise HTTPException(status_code=400, detail=f"Format nicht erlaubt: {ext}")
verarbeitete_bilder = []
for datei in dateien:
try:
verarbeitete_bilder.append(dokument_scan.erkenne_und_entzerre(datei.file.read()))
except dokument_scan.DokumentScanFehler as e:
raise HTTPException(status_code=400, detail=str(e)) from e
pdf_bytes = dokument_scan.bilder_zu_pdf(verarbeitete_bilder)
dok_dir = os.path.join(settings.uploads_dir, "dokumente", str(patient.id))
os.makedirs(dok_dir, exist_ok=True)
unique_name = f"{int(time.time())}_scan.pdf"
with open(os.path.join(dok_dir, unique_name), "wb") as fh:
fh.write(pdf_bytes)
pfad = f"dokumente/{patient.id}/{unique_name}"
dokument = Dokument(
patient_id=patient.id,
typ=typ,
bezeichnung=bezeichnung
or f"{datetime.date.today().strftime('%d.%m.%Y')} ({len(dateien)} Seite{'n' if len(dateien) > 1 else ''})",
dateiname=unique_name,
pfad=pfad,
datum=datum or None,
notizen=notizen,
)
db.add(dokument)
db.commit()
db.refresh(dokument)
return dokument
@router.get("/api/patienten/{pid}/dokumente/{did}/datei")
def datei_herunterladen(
did: int, patient: Patient = Depends(get_owned_patient), db: Session = Depends(get_db)
+38
View File
@@ -0,0 +1,38 @@
import os
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app import dokument_scan, egk_ocr
from app.deps import require_active_access, require_login
from app.models import Nutzer
from app.schemas import EgkScanErgebnis
router = APIRouter(tags=["egk"], dependencies=[Depends(require_active_access)])
ERLAUBTE_EGK_ENDUNGEN = {".jpg", ".jpeg", ".png", ".webp"}
@router.post("/api/egk/scan", response_model=EgkScanErgebnis)
def egk_scannen(
dateien: list[UploadFile] = File(...),
_user: Nutzer = Depends(require_login),
):
"""Liest Name/Geburtsdatum/Versichertennummer/Krankenkasse von 1-2 Fotos
der elektronischen Gesundheitskarte (eGK) per OCR aus (siehe
app/egk_ocr.py). Bewusst nicht patientengebunden (kein pid im Pfad) -
wird sowohl beim Anlegen eines neuen Patienten (wo noch keine Patienten-
ID existiert) als auch beim Bearbeiten eines bestehenden genutzt; das
Ergebnis fuellt in beiden Faellen nur clientseitig Formularfelder vor,
es wird nichts serverseitig persistiert - weder das Kartenfoto noch das
Ergebnis selbst."""
if not dateien or len(dateien) > 2:
raise HTTPException(status_code=400, detail="Bitte 1-2 Fotos hochladen (Vorder-/Rückseite)")
for datei in dateien:
ext = os.path.splitext(datei.filename or "")[1].lower()
if ext not in ERLAUBTE_EGK_ENDUNGEN:
raise HTTPException(status_code=400, detail=f"Format nicht erlaubt: {ext}")
try:
return egk_ocr.erkenne_egk([d.file.read() for d in dateien])
except (egk_ocr.EgkScanFehler, dokument_scan.DokumentScanFehler) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@@ -10,6 +10,13 @@ router = APIRouter(tags=["einstellungen"])
_PUBLIC_KEYS = (
"firmenname", "firmen_logo", "firmen_strasse", "firmen_plz", "firmen_ort", "firmen_tel",
"rechtstext_impressum", "rechtstext_datenschutz", "rechtstext_agb",
# Landingpage-Texte (Hero + SEO-Meta) - admin-editierbar, siehe
# Backend-Einstellungen "Landingpage & SEO". Bewusst nur diese vier Texte
# ueber die Einstellungen editierbar, nicht die Feature-Kacheln selbst
# (die sind 1:1 an echte App-Funktionen gebunden - eine frei editierbare
# Kachelliste koennte sonst Marketing-Text zeigen, der nicht mehr zur
# tatsaechlichen Funktion passt).
"landing_hero_titel", "landing_hero_text", "landing_seo_title", "landing_seo_description",
)
@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from app.deps import require_active_access, require_login
from app.schemas import KrisenGuideOut
from app.static_data.krisenguides import KRISENGUIDES
router = APIRouter(tags=["guides"], dependencies=[Depends(require_active_access)])
@router.get("/api/krisenguides", response_model=list[KrisenGuideOut])
def krisenguides(_=Depends(require_login)):
return KRISENGUIDES
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from app.deps import require_active_access, require_login
from app.static_data.katalog import KATALOG, get_by_id, get_kategorien, search
from app.static_data.pflegepauschbetrag import pflegepauschbetrag_fuer
router = APIRouter(tags=["katalog"], dependencies=[Depends(require_active_access)])
@@ -60,6 +61,7 @@ def berechnen(payload: dict, _=Depends(require_login)):
"verhinderungspflege": 1612 if grad >= 2 else 0,
"kurzzeitpflege": 1774 if grad >= 2 else 0,
"wohnumfeld": 4180 if grad >= 1 else 0,
"pflegepauschbetrag": pflegepauschbetrag_fuer(grad),
"relevant_count": len(relevant),
"relevant": relevant[:10],
}
@@ -0,0 +1,178 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db import get_db
from app.deps import get_owned_patient, require_active_access, require_paid_access
from app.models import Leistung, LeistungAusgabe, Nutzer, Patient
from app.schemas import (
LeistungAusgabeCreate,
LeistungAusgabeOut,
LeistungBudgetUpdate,
LeistungCreate,
LeistungenAusKatalogRequest,
LeistungOut,
LeistungRolleUpdate,
)
from app.static_data.katalog import get_by_id as katalog_get_by_id
router = APIRouter(tags=["leistungen"], dependencies=[Depends(require_active_access)])
def _owned_leistung_or_404(db: Session, user: Nutzer, lid: int) -> Leistung:
leistung = db.get(Leistung, lid)
if leistung is None or (user.rolle != "admin" and leistung.patient.nutzer_id != user.id):
raise HTTPException(status_code=404, detail="Nicht gefunden")
return leistung
@router.get("/api/patienten/{pid}/leistungen", response_model=list[LeistungOut])
def liste(patient: Patient = Depends(get_owned_patient), db: Session = Depends(get_db)):
return (
db.query(Leistung)
.filter(Leistung.patient_id == patient.id)
.order_by(Leistung.aktiv.desc(), Leistung.id.desc())
.all()
)
@router.post("/api/patienten/{pid}/leistungen", response_model=LeistungOut, status_code=201)
def anlegen(
payload: LeistungCreate,
patient: Patient = Depends(get_owned_patient),
db: Session = Depends(get_db),
_: Nutzer = Depends(require_paid_access),
):
leistung = Leistung(patient_id=patient.id, **payload.model_dump())
db.add(leistung)
db.commit()
db.refresh(leistung)
return leistung
@router.post("/api/patienten/{pid}/leistungen/katalog", response_model=list[LeistungOut], status_code=201)
def aus_katalog_anlegen(
payload: LeistungenAusKatalogRequest,
patient: Patient = Depends(get_owned_patient),
db: Session = Depends(get_db),
_: Nutzer = Depends(require_paid_access),
):
"""Legt fuer jede uebergebene Katalog-ID eine Leistung an - genutzt vom
Leistungsauswahl-Schritt im "Neuer Patient"-Wizard und vom Katalog-
Schnellauswahl-Feld auf der Patientenseite (Kundenfeedback: Interview soll
die laut Leistungskatalog moeglichen Bezuege direkt auswaehlbar machen).
Der Katalog speichert Betrag/Voraussetzung nur als Freitext (variiert je
Pflegegrad) - deshalb wird betrag hier bewusst leer gelassen und die
Katalog-Kurzbeschreibung stattdessen in notizen uebernommen; der genaue
Betrag kann spaeter individuell nachgetragen werden."""
ergebnis: list[Leistung] = []
for katalog_id in payload.katalog_ids:
eintrag = katalog_get_by_id(katalog_id)
if eintrag is None:
continue
leistung = Leistung(
patient_id=patient.id,
typ=eintrag["kategorie"],
bezeichnung=eintrag["name"],
katalog_id=eintrag["id"],
notizen=(
f"{eintrag['kurz']}\n"
f"Rechtsgrundlage: {eintrag['rechtsgrundlage']}\n"
f"Richtwert: {eintrag['betrag']}"
),
)
db.add(leistung)
ergebnis.append(leistung)
db.commit()
for leistung in ergebnis:
db.refresh(leistung)
return ergebnis
@router.patch("/api/leistungen/{lid}/rolle", response_model=LeistungOut)
def rolle_setzen(
lid: int,
payload: LeistungRolleUpdate,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_paid_access),
):
leistung = _owned_leistung_or_404(db, user, lid)
leistung.rolle = payload.rolle
db.commit()
db.refresh(leistung)
return leistung
@router.patch("/api/leistungen/{lid}/deaktivieren", response_model=LeistungOut)
def deaktivieren(lid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_paid_access)):
leistung = _owned_leistung_or_404(db, user, lid)
leistung.aktiv = False
db.commit()
db.refresh(leistung)
return leistung
@router.patch("/api/leistungen/{lid}/aktivieren", response_model=LeistungOut)
def aktivieren(lid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_paid_access)):
leistung = _owned_leistung_or_404(db, user, lid)
leistung.aktiv = True
db.commit()
db.refresh(leistung)
return leistung
@router.delete("/api/leistungen/{lid}", status_code=204)
def loeschen(lid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_paid_access)):
leistung = _owned_leistung_or_404(db, user, lid)
db.delete(leistung)
db.commit()
@router.patch("/api/leistungen/{lid}/budget", response_model=LeistungOut)
def budget_setzen(
lid: int,
payload: LeistungBudgetUpdate,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_paid_access),
):
"""Jaehrliches Budget fuer den Budget-Tracker (Tester-Feedback: visuelle
Restbetrag-Uebersicht, z.B. fuer den Entlastungsbetrag)."""
leistung = _owned_leistung_or_404(db, user, lid)
leistung.budget_jahr = payload.budget_jahr
db.commit()
db.refresh(leistung)
return leistung
@router.get("/api/leistungen/{lid}/ausgaben", response_model=list[LeistungAusgabeOut])
def ausgaben_liste(lid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_paid_access)):
_owned_leistung_or_404(db, user, lid)
return (
db.query(LeistungAusgabe)
.filter(LeistungAusgabe.leistung_id == lid)
.order_by(LeistungAusgabe.datum.desc(), LeistungAusgabe.id.desc())
.all()
)
@router.post("/api/leistungen/{lid}/ausgaben", response_model=LeistungAusgabeOut, status_code=201)
def ausgabe_anlegen(
lid: int,
payload: LeistungAusgabeCreate,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_paid_access),
):
_owned_leistung_or_404(db, user, lid)
ausgabe = LeistungAusgabe(leistung_id=lid, **payload.model_dump())
db.add(ausgabe)
db.commit()
db.refresh(ausgabe)
return ausgabe
@router.delete("/api/ausgaben/{aid}", status_code=204)
def ausgabe_loeschen(aid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_paid_access)):
ausgabe = db.get(LeistungAusgabe, aid)
if ausgabe is None or (user.rolle != "admin" and ausgabe.leistung.patient.nutzer_id != user.id):
raise HTTPException(status_code=404, detail="Nicht gefunden")
db.delete(ausgabe)
db.commit()
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Query, Session
@@ -13,6 +15,7 @@ from app.schemas import (
PatientUpdate,
PflegegradPatch,
)
from app.validierung import pruefe_versichertennummer
router = APIRouter(
prefix="/api/patienten", tags=["patienten"], dependencies=[Depends(require_active_access)]
@@ -51,7 +54,27 @@ def anlegen(
# zusaetzliche Patient wird ueber berechne_abo_preis() automatisch
# kostenpflichtig, statt hart begrenzt zu sein - siehe CLAUDE.md
# "Subscription / payment scaffolding".
patient = Patient(**payload.model_dump(), nutzer_id=user.id)
if not payload.berechtigung_bestaetigt:
raise HTTPException(
status_code=400,
detail="Bitte bestätigen Sie Ihre Berechtigung zur Datenverarbeitung für diese Person",
)
try:
pruefe_versichertennummer(payload.versichertennummer, payload.krankenkasse, payload.versicherungsart)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
patient_data = payload.model_dump(exclude={"berechtigung_bestaetigt"})
patient = Patient(
**patient_data,
nutzer_id=user.id,
# Nachweis nach Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht), dass der
# Nutzer je Patient ausdruecklich bestaetigt hat, zur Verarbeitung
# von dessen Daten berechtigt zu sein - siehe Nutzungsbedingungen
# Ziffer 5 und Datenschutzerklaerung Ziffer 7. Pro Patient erfasst
# (nicht nur einmal pro Konto), da jede Person eine eigene
# Berechtigungsgrundlage haben kann.
berechtigung_bestaetigt_am=datetime.now(timezone.utc),
)
db.add(patient)
db.commit()
db.refresh(patient)
@@ -64,7 +87,16 @@ def aktualisieren(
patient: Patient = Depends(get_owned_patient),
db: Session = Depends(get_db),
):
for feld, wert in payload.model_dump(exclude_unset=True).items():
aenderungen = payload.model_dump(exclude_unset=True)
try:
pruefe_versichertennummer(
aenderungen.get("versichertennummer", patient.versichertennummer),
aenderungen.get("krankenkasse", patient.krankenkasse),
aenderungen.get("versicherungsart", patient.versicherungsart),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
for feld, wert in aenderungen.items():
setattr(patient, feld, wert)
db.commit()
db.refresh(patient)
+20
View File
@@ -0,0 +1,20 @@
import re
from fastapi import APIRouter, HTTPException
from app.schemas import PlzOrtOut
from app.static_data.plz_orte import get_ort
router = APIRouter(tags=["plz"])
PLZ_MUSTER = re.compile(r"^\d{5}$")
@router.get("/api/plz/{plz}", response_model=PlzOrtOut)
def ort_nachschlagen(plz: str):
if not PLZ_MUSTER.match(plz):
raise HTTPException(status_code=400, detail="PLZ muss aus 5 Ziffern bestehen")
ort = get_ort(plz)
if ort is None:
raise HTTPException(status_code=404, detail="PLZ nicht gefunden")
return {"plz": plz, "ort": ort}
@@ -0,0 +1,96 @@
import re
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import umkreissuche as umkreissuche_logik
from app.db import get_db
from app.deps import require_active_access, require_login
from app.models import Nutzer, UmkreissucheFavorit
from app.schemas import (
UmkreissucheFavoritCreate,
UmkreissucheFavoritOut,
UmkreissucheKategorieOut,
UmkreissucheResponse,
)
router = APIRouter(tags=["umkreissuche"], dependencies=[Depends(require_active_access)])
PLZ_MUSTER = re.compile(r"^\d{5}$")
ERLAUBTE_RADIEN = {5, 10, 15, 25, 50}
@router.get("/api/umkreissuche/kategorien", response_model=list[UmkreissucheKategorieOut])
def kategorien(_=Depends(require_login)):
return [
{"kategorie": k, "label": v["label"]} for k, v in umkreissuche_logik.KATEGORIEN.items()
]
@router.get("/api/umkreissuche", response_model=UmkreissucheResponse)
def suche(plz: str, kategorie: str, radius_km: int = 15, _=Depends(require_login)):
if not PLZ_MUSTER.match(plz):
raise HTTPException(status_code=400, detail="PLZ muss aus 5 Ziffern bestehen")
if kategorie not in umkreissuche_logik.KATEGORIEN:
raise HTTPException(status_code=400, detail="Unbekannte Kategorie")
if radius_km not in ERLAUBTE_RADIEN:
raise HTTPException(status_code=400, detail="Ungueltiger Umkreis")
try:
treffer = umkreissuche_logik.suche(plz, kategorie, radius_km)
except umkreissuche_logik.UmkreissucheFehler as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return {"plz": plz, "kategorie": kategorie, "radius_km": radius_km, "treffer": treffer}
@router.get("/api/umkreissuche/favoriten", response_model=list[UmkreissucheFavoritOut])
def favoriten_liste(
db: Session = Depends(get_db), user: Nutzer = Depends(require_login)
):
return (
db.query(UmkreissucheFavorit)
.filter(UmkreissucheFavorit.nutzer_id == user.id)
.order_by(UmkreissucheFavorit.erstellt_am.desc())
.all()
)
@router.post("/api/umkreissuche/favoriten", response_model=UmkreissucheFavoritOut, status_code=201)
def favorit_anlegen(
payload: UmkreissucheFavoritCreate,
db: Session = Depends(get_db),
user: Nutzer = Depends(require_login),
):
bestehender = (
db.query(UmkreissucheFavorit)
.filter(
UmkreissucheFavorit.nutzer_id == user.id,
UmkreissucheFavorit.osm_type == payload.osm_type,
UmkreissucheFavorit.osm_id == payload.osm_id,
)
.first()
)
if bestehender is not None:
return bestehender
favorit = UmkreissucheFavorit(nutzer_id=user.id, **payload.model_dump())
db.add(favorit)
db.commit()
db.refresh(favorit)
return favorit
@router.delete("/api/umkreissuche/favoriten/{fid}", status_code=204)
def favorit_loeschen(
fid: int, db: Session = Depends(get_db), user: Nutzer = Depends(require_login)
):
favorit = (
db.query(UmkreissucheFavorit)
.filter(UmkreissucheFavorit.id == fid, UmkreissucheFavorit.nutzer_id == user.id)
.first()
)
if favorit is None:
raise HTTPException(status_code=404, detail="Nicht gefunden")
db.delete(favorit)
db.commit()
+246 -3
View File
@@ -1,4 +1,6 @@
import datetime
import decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -12,10 +14,22 @@ class RegistrierenRequest(BaseModel):
benutzername: str
passwort: str
name: str
email: str
rg_name: str
rg_strasse: str
rg_plz: str
rg_ort: str
zustimmung_sofortbeginn: bool = False
zustimmung_widerrufsverzicht: bool = False
class PasswortVergessenRequest(BaseModel):
email: str
class PasswortZuruecksetzenRequest(BaseModel):
token: str
neues_passwort: str
class AuthResponse(BaseModel):
@@ -39,12 +53,24 @@ class MeResponse(BaseModel):
id: int
benutzername: str
name: str | None
email: str | None
rolle: str
ist_demo: bool
selbst_registriert: bool
app_version: str
changelog_ungesehen: list[ChangelogEintragOut]
schreibzugriff_gesperrt: bool
rg_name: str | None = None
rg_strasse: str | None = None
rg_plz: str | None = None
rg_ort: str | None = None
reminder_aktiv: bool = False
reminder_uhrzeit: datetime.time | None = None
class ReminderEinstellungenUpdate(BaseModel):
reminder_aktiv: bool
reminder_uhrzeit: datetime.time | None = None
class PatientBase(BaseModel):
@@ -67,7 +93,7 @@ class PatientBase(BaseModel):
class PatientCreate(PatientBase):
pass
berechtigung_bestaetigt: bool = False
class PatientUpdate(BaseModel):
@@ -117,11 +143,30 @@ class NotizVerlaufOut(BaseModel):
geaendert_am: datetime.datetime
class DashboardPatientKachel(BaseModel):
id: int
vorname: str
nachname: str
pflegegrad: int
patient_status: str
offene_antraege: int
offene_aufgaben: int
class DashboardOffenerAntragEintrag(BaseModel):
patient_id: int
patient_name: str
anzahl: int
naechster_typ: str
naechster_status: str
class DashboardOut(BaseModel):
patienten_anzahl: int
offene_antraege: int
offene_aufgaben: int
letzte_patienten: list[PatientOut]
patienten_uebersicht: list[DashboardPatientKachel]
offene_antraege_nach_patient: list[DashboardOffenerAntragEintrag]
class PflegegradOption(BaseModel):
@@ -214,6 +259,7 @@ class MusterbriefRequest(BaseModel):
absender_strasse: str = ""
absender_plz_ort: str = ""
absender_telefon: str = ""
absender_email: str | None = None
krankenkasse: str | None = None
typ: str = "erstantrag"
versicherter_name: str
@@ -227,6 +273,7 @@ class NutzerOut(BaseModel):
id: int
benutzername: str
name: str | None
email: str | None = None
rolle: str
aktiv: bool
erstellt_am: datetime.datetime
@@ -250,6 +297,7 @@ class NutzerCreate(BaseModel):
benutzername: str
passwort: str
name: str = ""
email: str = ""
rolle: str = "nutzer"
rg_name: str = ""
rg_strasse: str = ""
@@ -259,6 +307,7 @@ class NutzerCreate(BaseModel):
class NutzerUpdate(BaseModel):
name: str = ""
email: str = ""
rolle: str = "nutzer"
aktiv: bool = True
passwort: str | None = None
@@ -272,6 +321,12 @@ class BegruendungRequest(BaseModel):
begruendung: str
class BackupEintragOut(BaseModel):
dateiname: str
groesse_bytes: int
erstellt_am: datetime.datetime
class AboFreischaltenRequest(BaseModel):
monate: int = 1
begruendung: str
@@ -305,7 +360,7 @@ class ChangelogCreate(BaseModel):
class RechnungRequest(BaseModel):
betrag_brutto: float
leistungsbeschreibung: str = "Pflegepilot – Nutzung der Anwendung"
leistungsbeschreibung: str = "MeinPflegeFuchs – Nutzung der Anwendung"
class TagebuchCreate(BaseModel):
@@ -384,6 +439,64 @@ class KalenderEvent(BaseModel):
ueberfaellig: bool
LeistungRolle = Literal["eigenleistung", "extern_beantragen"]
class LeistungOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
patient_id: int
typ: str
bezeichnung: str
betrag: decimal.Decimal | None
einheit: str
aktiv: bool
seit: datetime.date | None
katalog_id: str | None
notizen: str | None
rolle: LeistungRolle | None
budget_jahr: decimal.Decimal | None
class LeistungCreate(BaseModel):
typ: str
bezeichnung: str
betrag: decimal.Decimal | None = None
einheit: str = "monatlich"
seit: datetime.date | None = None
notizen: str | None = None
rolle: LeistungRolle | None = None
class LeistungenAusKatalogRequest(BaseModel):
katalog_ids: list[str]
class LeistungRolleUpdate(BaseModel):
rolle: LeistungRolle | None = None
class LeistungBudgetUpdate(BaseModel):
budget_jahr: decimal.Decimal | None = None
class LeistungAusgabeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
leistung_id: int
betrag: decimal.Decimal
datum: datetime.date
beschreibung: str | None
class LeistungAusgabeCreate(BaseModel):
betrag: decimal.Decimal
datum: datetime.date
beschreibung: str | None = None
class KrankenkasseOut(BaseModel):
name: str
kurzname: str
@@ -402,12 +515,30 @@ class RentenversicherungOut(BaseModel):
ort: str
class DokumentTypOut(BaseModel):
typ: str
label: str
class ChecklistePunktOut(BaseModel):
typ: str
label: str
class KrisenGuideOut(BaseModel):
id: str
titel: str
kurz: str
schritte: list[str]
class AdressaenderungRequest(BaseModel):
neue_strasse: str
neue_plz: str
neue_ort: str
gueltig_ab: datetime.date | None = None
absender_telefon: str = ""
absender_email: str | None = None
krankenkasse: str | None = None
rentenversicherungstraeger: str | None = None
krankenkasse_benachrichtigen: bool = True
@@ -418,3 +549,115 @@ class AdressaenderungOut(BaseModel):
patient: PatientOut
krankenkasse_brief: AntragOut | None = None
rentenversicherung_brief: AntragOut | None = None
class PlzOrtOut(BaseModel):
plz: str
ort: str
class EgkScanErgebnis(BaseModel):
vorname: str | None = None
nachname: str | None = None
geburtsdatum: str | None = None
versichertennummer: str | None = None
krankenkasse: str | None = None
class UmkreissucheKategorieOut(BaseModel):
kategorie: str
label: str
class UmkreissucheTrefferOut(BaseModel):
osm_type: str
osm_id: int
name: str
kategorie: str
strasse: str | None = None
plz: str | None = None
ort: str | None = None
telefon: str | None = None
email: str | None = None
webseite: str | None = None
entfernung_km: float
class UmkreissucheResponse(BaseModel):
plz: str
kategorie: str
radius_km: int
treffer: list[UmkreissucheTrefferOut]
class UmkreissucheFavoritCreate(BaseModel):
osm_type: str
osm_id: int
name: str
kategorie: str
strasse: str | None = None
plz: str | None = None
ort: str | None = None
telefon: str | None = None
email: str | None = None
webseite: str | None = None
class UmkreissucheFavoritOut(BaseModel):
id: int
osm_type: str
osm_id: int
name: str
kategorie: str
strasse: str | None = None
plz: str | None = None
ort: str | None = None
telefon: str | None = None
email: str | None = None
webseite: str | None = None
model_config = ConfigDict(from_attributes=True)
class VollmachtRequest(BaseModel):
bevollmaechtigter_name: str
bevollmaechtigter_strasse: str = ""
bevollmaechtigter_plz_ort: str = ""
krankenkasse: str | None = None
ort: str = ""
class PatientenverfuegungTextbausteineOut(BaseModel):
situationen: list[dict]
massnahmen: dict
wiederbelebung_a_optionen: list[dict]
wiederbelebung_b_optionen: list[dict]
ort_optionen: list[dict]
vertrauensperson_optionen: list[dict]
organspende_optionen: list[dict]
organspende_konflikt_optionen: list[dict]
class PatientenverfuegungRequest(BaseModel):
situationen: list[str] = Field(default_factory=list)
situation_eigene: str | None = None
massnahmen: dict[str, str] = Field(default_factory=dict)
schmerzbehandlung_zusatz: bool = False
wiederbelebung_a: str
wiederbelebung_b: str = "keine_regelung"
ort: str = "keine_angabe"
beistand_personen: str | None = None
beistand_kirche: str | None = None
hospizlicher_beistand: bool = False
schweigepflicht_personen: str | None = None
vertrauensperson: str = "bevollmaechtigter"
vertrauensperson_andere_name: str | None = None
vollmacht_name: str | None = None
betreuer_name: str | None = None
organspende: str = "keine_angabe"
organspende_konflikt: str | None = None
aufklaerungsverzicht: bool = False
informiert_bei: str | None = None
beraten_durch: str | None = None
aerztliche_bestaetigung_freifeld: bool = True
ausstellungsort: str = ""
@@ -0,0 +1,56 @@
"""Orientierungshilfe: ueblicherweise angeforderte Unterlagen je Antragsart
(Tester-Feedback 2026-07-27: "beim Beantragen einmal pruefen ob alles
Notwendige fuer den Antrag schon hochgeladen worden ist, damit dies im Falle
der Pruefung direkt bereitliegt").
Recherchiert je Antragsart anhand oeffentlicher Ratgeber- und
Behoerden-/Sozialamtsquellen zu den jeweils einschlaegigen Vorschriften
(§ 18/§ 33 SGB XI fuer Erst-/Hoeherstufungsantrag und Widerspruch nach § 84
SGG, § 99 SGB IX fuer Eingliederungshilfe, § 41 SGB XII fuer Grundsicherung,
§ 72 SGB XII fuer Blindenhilfe). AUSDRUECKLICH KEINE rechtsverbindliche oder
abschliessende Liste - der tatsaechliche Umfang variiert je Kasse/Amt/
Bundesland und Einzelfall, das gilt besonders fuer Eingliederungshilfe und
Grundsicherung, wo die zustaendige Stelle den Bedarf individuell festlegt.
Deshalb bewusst NICHT blockierend eingesetzt (Kundenentscheidung 2026-07-27,
siehe routers/briefe.py): reine Erinnerungs-Checkliste im Frontend, kein
Gate fuer das Erstellen eines Antrags/Briefs.
"adressaenderung" hat bewusst keinen Eintrag - eine reine Ummeldung braucht
keine Nachweise.
"""
from app.static_data.dokument_typen import DOKUMENT_TYPEN
_LABEL = {d["typ"]: d["label"] for d in DOKUMENT_TYPEN}
ANTRAG_CHECKLISTE: dict[str, list[str]] = {
# § 18/§ 33 SGB XI - Pflegegrad-Erstantrag
"erstantrag": ["aerztlicher_befund", "medikamentenplan", "pflegetagebuch", "hilfsmittel_nachweis"],
# Hoeherstufung: gleiche Basis wie Erstantrag, zusaetzlich der bisherige Bescheid
"hoeherst": ["aerztlicher_befund", "medikamentenplan", "pflegetagebuch", "bescheid_kopie"],
# Widerspruch nach § 84 SGG gegen einen Pflegegrad-Bescheid
"widerspruch": ["bescheid_kopie", "aerztlicher_befund", "pflegetagebuch", "medikamentenplan"],
# § 99 SGB IX - Umfang ist laut Recherche ausdruecklich einzelfallabhaengig,
# hier nur die typischerweise angeforderte Grundausstattung
"eingliederung": ["behinderung_nachweis", "einkommensnachweis", "vermoegensnachweis"],
# § 41 SGB XII - orientiert an oeffentlichen Checklisten der Sozialaemter
"grundsicherung": [
"einkommensnachweis",
"vermoegensnachweis",
"mietvertrag",
"versicherungsnachweis",
"schwerbehindertenausweis",
],
# § 72 SGB XII - Anspruch wird ueber Merkzeichen "Bl" nachgewiesen
"blindenhilfe": [
"schwerbehindertenausweis",
"vermoegensnachweis",
"versicherungsnachweis",
"einkommensnachweis",
],
}
def checkliste_fuer(typ: str) -> list[dict]:
return [{"typ": t, "label": _LABEL[t]} for t in ANTRAG_CHECKLISTE.get(typ, [])]
@@ -0,0 +1,22 @@
"""Dokumenttyp-Taxonomie fuer hochgeladene Patientenunterlagen. Bisher gab es
keine Kategorisierung - jeder Upload lief fest auf "sonstiges" (siehe
routers/dokumente.py vor diesem Feature). Eingefuehrt, damit die
Antrags-Checkliste (app/static_data/antrag_checkliste.py) automatisch
erkennen kann, welche der ueblicherweise benoetigten Unterlagen fuer einen
Patienten schon vorliegen - Tester-Feedback: Checkliste vor Antragstellung,
damit im Pruefungsfall alles bereitliegt."""
DOKUMENT_TYPEN = [
{"typ": "aerztlicher_befund", "label": "Ärztlicher Befund / Arztbericht"},
{"typ": "medikamentenplan", "label": "Medikamentenplan"},
{"typ": "pflegetagebuch", "label": "Pflegetagebuch"},
{"typ": "hilfsmittel_nachweis", "label": "Nachweis über Hilfsmittel"},
{"typ": "bescheid_kopie", "label": "Kopie eines Bescheids"},
{"typ": "behinderung_nachweis", "label": "Nachweis der Behinderung (z. B. ärztliches Gutachten)"},
{"typ": "schwerbehindertenausweis", "label": "Schwerbehindertenausweis"},
{"typ": "einkommensnachweis", "label": "Einkommensnachweis"},
{"typ": "vermoegensnachweis", "label": "Vermögensnachweis (z. B. Kontoauszüge)"},
{"typ": "mietvertrag", "label": "Mietvertrag / Nebenkostenabrechnung"},
{"typ": "versicherungsnachweis", "label": "Nachweis Kranken-/Pflegeversicherung"},
{"typ": "sonstiges", "label": "Sonstiges"},
]
@@ -1184,6 +1184,230 @@ KATALOG = [
"frist": "Antrag beim Versorgungsamt",
"tags": ["blind", "bayern", "geld", "monatlich"]
},
# Landesblindengeld/-pflegegeld der uebrigen 15 Bundeslaender (ergaenzt
# 2026-07-27, Kundenfeedback: "wie sieht es mit den anderen Bundeslaendern
# aus?"). Betraege einzeln je Bundesland gegen offizielle Quellen (Landes-
# gesetz-Text, Landesamt-Website) bzw. den Fachverband DBSV recherchiert,
# nicht aus einer einzelnen Sammel-Uebersicht uebernommen. WICHTIG: die
# meisten dieser Betraege werden jaehrlich zum 1. Juli entsprechend der
# Rentenwert-Anpassung erhoeht (mehrere Bundeslaender hatten zum
# 1.4.2026 zusaetzlich eine gesonderte Erhoehung) - vor erneuter Verwendung
# nach laengerer Zeit unbedingt erneut pruefen, genau wie bei den
# Krankenkassen-Adressen. Bewusst nur das NICHT einkommensabhaengige
# Landesblindengeld/-pflegegeld erfasst (nicht die zusaetzliche, einkommens-
# abhaengige Bundes-Blindenhilfe nach § 72 SGB XII, die manche Betroffene
# ergaenzend erhalten koennen - das ist eine eigene Leistung).
{
"id": "land_bw_blindenhilfe",
"kategorie": "Landesleistungen (Baden-Württemberg)",
"zielgruppe": ["behinderte"],
"name": "Landesblindenhilfe Baden-Württemberg",
"kurz": "Monatliches Landesgeld für blinde Menschen in Baden-Württemberg",
"rechtsgrundlage": "Gesetz über die Landesblindenhilfe (BliHG)",
"betrag": "410 € / Monat (ab 18); 205 € / Monat (unter 18)",
"voraussetzung": "Wohnsitz Baden-Württemberg; Blindheit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landratsamt / Stadtkreis (KVJS)",
"frist": "Antrag beim Landratsamt bzw. Stadtkreis",
"tags": ["blind", "baden-wuerttemberg", "geld", "monatlich"]
},
{
"id": "land_berlin_pflegegeld",
"kategorie": "Landesleistungen (Berlin)",
"zielgruppe": ["behinderte"],
"name": "Landespflegegeld Berlin (Blinden-/Taubblindengeld)",
"kurz": "Monatliches Landesgeld für blinde, hochgradig sehbehinderte, gehörlose und taubblinde Menschen in Berlin",
"rechtsgrundlage": "Landespflegegeldgesetz (LPflGG)",
"betrag": "761,54 € / Monat (blind) · 1.189 € (taubblind) · 190,38 € (hochgradig sehbehindert/gehörlos) · 380,76 € (beides)",
"voraussetzung": "Wohnsitz Berlin; entsprechendes Merkzeichen",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Bezirksamt (Begutachtung durch LAGeSo)",
"frist": "Antrag beim zuständigen Bezirksamt",
"tags": ["blind", "berlin", "geld", "monatlich", "taubblind"]
},
{
"id": "land_brandenburg_teilhabegeld",
"kategorie": "Landesleistungen (Brandenburg)",
"zielgruppe": ["behinderte"],
"name": "Teilhabegeld Brandenburg",
"kurz": "Monatliches Landesgeld für blinde und gehörlose Menschen in Brandenburg (bis 2024 „Landespflegegeld“ genannt)",
"rechtsgrundlage": "Landesteilhabegeldgesetz (LTeilhGG)",
"betrag": "425 € / Monat (blind, ab 18); 212,50 € (unter 18); Gehörlose niedriger gestaffelt",
"voraussetzung": "Wohnsitz Brandenburg; Blindheit oder Gehörlosigkeit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesamt für Soziales und Versorgung (LASV)",
"frist": "Ab 2026 jährliche Dynamisierung zum 1. Juli entsprechend Rentenwert-Anpassung",
"tags": ["blind", "brandenburg", "geld", "monatlich", "gehoerlos"]
},
{
"id": "land_bremen_blindengeld",
"kategorie": "Landesleistungen (Bremen)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Bremen",
"kurz": "Monatliches Landesgeld für blinde Menschen in Bremen und Bremerhaven",
"rechtsgrundlage": "Bremisches Landesblindengeldgesetz",
"betrag": "536,96 € / Monat (ab 18); 268,48 € (unter 18)",
"voraussetzung": "Wohnsitz Bremen/Bremerhaven; Blindheit. Pflegeversicherungsleistungen werden ab Pflegegrad 2 angerechnet",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Amt für Soziale Dienste",
"frist": "Antrag beim Amt für Soziale Dienste",
"tags": ["blind", "bremen", "geld", "monatlich"]
},
{
"id": "land_hamburg_blindengeld",
"kategorie": "Landesleistungen (Hamburg)",
"zielgruppe": ["behinderte"],
"name": "Blindengeld Hamburg",
"kurz": "Monatliches Landesgeld für blinde Menschen in Hamburg, unabhängig vom Alter",
"rechtsgrundlage": "Hamburgisches Blindengeldgesetz (HmbBlinGG)",
"betrag": "ca. 725 € / Monat",
"voraussetzung": "Wohnsitz Hamburg; Blindheit. Kürzung bei Bezug von Pflegeleistungen bzw. Heimunterbringung",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Sozialbehörde / Bezirksamt",
"frist": "Antrag beim zuständigen Bezirksamt",
"tags": ["blind", "hamburg", "geld", "monatlich"]
},
{
"id": "land_hessen_blindengeld",
"kategorie": "Landesleistungen (Hessen)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Hessen",
"kurz": "Monatliches Landesgeld für blinde Menschen in Hessen",
"rechtsgrundlage": "Landesblindengeldgesetz (LBliGG)",
"betrag": "785,34 € / Monat (ab 18); 457,38 € (unter 18)",
"voraussetzung": "Wohnsitz Hessen; Blindheit (Merkzeichen Bl)",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landeswohlfahrtsverband Hessen (LWV Hessen)",
"frist": "Antrag beim LWV Hessen",
"tags": ["blind", "hessen", "geld", "monatlich"]
},
{
"id": "land_mv_blindengeld",
"kategorie": "Landesleistungen (Mecklenburg-Vorpommern)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Mecklenburg-Vorpommern",
"kurz": "Monatliches Landesgeld für blinde Menschen in Mecklenburg-Vorpommern",
"rechtsgrundlage": "Gesetz über die Gewährung von Landesblindengeld (LBlGG M-V)",
"betrag": "430 € / Monat (ab 18)",
"voraussetzung": "Wohnsitz Mecklenburg-Vorpommern; Blindheit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesamt für Gesundheit und Soziales (LAGuS)",
"frist": "Antrag beim LAGuS",
"tags": ["blind", "mecklenburg-vorpommern", "geld", "monatlich"]
},
{
"id": "land_niedersachsen_blindengeld",
"kategorie": "Landesleistungen (Niedersachsen)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Niedersachsen",
"kurz": "Monatliches Landesgeld für blinde Menschen in Niedersachsen",
"rechtsgrundlage": "Gesetz über das Landesblindengeld für Zivilblinde (NBlindG)",
"betrag": "450 € / Monat außerhalb von Einrichtungen; 225 € in Einrichtungen",
"voraussetzung": "Wohnsitz Niedersachsen; Blindheit (Merkzeichen Bl)",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesamt für Soziales, Jugend und Familie",
"frist": "Antrag beim Landesamt für Soziales, Jugend und Familie",
"tags": ["blind", "niedersachsen", "geld", "monatlich"]
},
{
"id": "land_nrw_blindengeld",
"kategorie": "Landesleistungen (Nordrhein-Westfalen)",
"zielgruppe": ["behinderte"],
"name": "Blindengeld Nordrhein-Westfalen",
"kurz": "Monatliches Landesgeld für blinde Menschen in NRW, altersgestaffelt",
"rechtsgrundlage": "Gesetz über die Hilfen für Blinde und Gehörlose (GHBG)",
"betrag": "913,19 € / Monat (unter 60 Jahre); 473 € (ab 60 Jahre); 457,38 € (Kinder/Jugendliche)",
"voraussetzung": "Wohnsitz NRW; Blindheit (Merkzeichen Bl)",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landschaftsverband Rheinland (LVR) bzw. Landschaftsverband Westfalen-Lippe (LWL), je nach Landesteil",
"frist": "Antrag beim zuständigen Landschaftsverband",
"tags": ["blind", "nordrhein-westfalen", "geld", "monatlich"]
},
{
"id": "land_rlp_pflegegeld",
"kategorie": "Landesleistungen (Rheinland-Pfalz)",
"zielgruppe": ["behinderte"],
"name": "Landespflegegeld Rheinland-Pfalz",
"kurz": "Monatliches Landesgeld für schwerbehinderte Pflegebedürftige (GdB 100), insbesondere Blinde",
"rechtsgrundlage": "Landespflegegeldgesetz (LPflegeGG)",
"betrag": "384 € / Monat (ab 18); 192 € (unter 18)",
"voraussetzung": "Wohnsitz Rheinland-Pfalz; GdB 100. Entfällt bei Heim-/Anstaltsunterbringung über 4 Wochen",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesamt für Soziales, Jugend und Versorgung (LSJV)",
"frist": "Antrag beim LSJV",
"tags": ["blind", "rheinland-pfalz", "geld", "monatlich"]
},
{
"id": "land_saarland_teilhabegeld",
"kategorie": "Landesleistungen (Saarland)",
"zielgruppe": ["behinderte"],
"name": "Teilhabegeld (Blindheitshilfe) Saarland",
"kurz": "Monatliches Landesgeld für blinde und taubblinde Menschen im Saarland",
"rechtsgrundlage": "Gesetz zur Teilhabeförderung Blinder und Taubblinder (Landesteilhabegeldgesetz)",
"betrag": "460 € / Monat (blind, ab 18); 327 € (unter 18); Taubblindengeld zusätzlich 675 € (ab 18) / 476 € (unter 18)",
"voraussetzung": "Wohnsitz Saarland; Blindheit bzw. Taubblindheit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesamt für Soziales",
"frist": "Antrag beim Landesamt für Soziales",
"tags": ["blind", "saarland", "geld", "monatlich", "taubblind"]
},
{
"id": "land_sachsen_blindengeld",
"kategorie": "Landesleistungen (Sachsen)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Sachsen",
"kurz": "Monatliches Landesgeld für blinde, hochgradig sehbehinderte und gehörlose Menschen in Sachsen",
"rechtsgrundlage": "Landesblindengeldgesetz (LBlindG)",
"betrag": "380 € / Monat (blind, ab 14); Sehbehinderte 100 €; Gehörlose 150 €; Taubblinde zusätzlich 320 €",
"voraussetzung": "Wohnsitz Sachsen; Blindheit/Sehbehinderung/Gehörlosigkeit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Kommunaler Sozialverband Sachsen (KSV Sachsen) / zuständiger Landkreis",
"frist": "Antrag beim zuständigen Landkreis bzw. der kreisfreien Stadt",
"tags": ["blind", "sachsen", "geld", "monatlich"]
},
{
"id": "land_sachsenanhalt_blindengeld",
"kategorie": "Landesleistungen (Sachsen-Anhalt)",
"zielgruppe": ["behinderte"],
"name": "Blinden- und Gehörlosengeld Sachsen-Anhalt",
"kurz": "Monatliches Landesgeld für blinde und gehörlose Menschen in Sachsen-Anhalt",
"rechtsgrundlage": "Gesetz über das Blinden- und Gehörlosengeld (BliGG LSA)",
"betrag": "479,96 € / Monat (blind, ab 18, seit 1.7.2026); 333,32 € (unter 18); Gehörlose 69,32 €",
"voraussetzung": "Wohnsitz Sachsen-Anhalt; Blindheit bzw. Gehörlosigkeit. Jährliche Anpassung zum 1. Juli",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesverwaltungsamt Sachsen-Anhalt",
"frist": "Antrag beim Landesverwaltungsamt",
"tags": ["blind", "sachsen-anhalt", "geld", "monatlich"]
},
{
"id": "land_sh_blindengeld",
"kategorie": "Landesleistungen (Schleswig-Holstein)",
"zielgruppe": ["behinderte"],
"name": "Landesblindengeld Schleswig-Holstein",
"kurz": "Monatliches Landesgeld für blinde und taubblinde Menschen in Schleswig-Holstein",
"rechtsgrundlage": "Landesblindengeldgesetz (LBlGG)",
"betrag": "350 € / Monat (ab 18, seit 1.4.2026); 250 € (unter 18); Taubblinde 450 €",
"voraussetzung": "Wohnsitz Schleswig-Holstein; Blindheit",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Kreis bzw. kreisfreie Stadt (Sozialamt)",
"frist": "Antrag beim zuständigen Kreis/kreisfreier Stadt",
"tags": ["blind", "schleswig-holstein", "geld", "monatlich", "taubblind"]
},
{
"id": "land_thueringen_sinnesbehindertengeld",
"kategorie": "Landesleistungen (Thüringen)",
"zielgruppe": ["behinderte"],
"name": "Sinnesbehindertengeld Thüringen",
"kurz": "Monatliches Landesgeld für blinde, gehörlose und taubblinde Menschen in Thüringen",
"rechtsgrundlage": "Thüringer Gesetz über das Sinnesbehindertengeld (ThürSinnbGG)",
"betrag": "472 € / Monat (blind); 172 € (gehörlos); 644 € (taubblind)",
"voraussetzung": "Wohnsitz Thüringen; Blindheit/Gehörlosigkeit. Kürzung auf 215,23 € (Pflegegrad 2) bzw. 150,45 € (Pflegegrad 3–5) bei zusätzlichem Bezug häuslicher Pflegeleistungen",
"pflegegrad_min": 0, "pflegegrad_max": 5,
"antragsteller": "Landesverwaltungsamt / Sozialamt des zuständigen Landkreises",
"frist": "Antrag beim zuständigen Sozialamt",
"tags": ["blind", "thueringen", "geld", "monatlich", "gehoerlos"]
},
{
"id": "land_rundfunk",
"kategorie": "Landesleistungen (Allgemein)",
@@ -1,5 +1,5 @@
"""
Pflegepilot – Krankenkassen-Stammdaten
MeinPflegeFuchs – Krankenkassen-Stammdaten
Urspruenglich 1:1 aus der alten App (pflegepilot_alpha_v2/pflegepilot/krankenkassen.py)
portiert, am 2026-07-24 auf Vollstaendigkeit gegen die offizielle Liste des
GKV-Spitzenverbands (93 gesetzliche Kassen, Stand 2026) geprueft und ergaenzt -
@@ -0,0 +1,70 @@
"""Krisen-Checklisten (Tester-Feedback: "Checklisten für Krisenmomente:
Schnell abrufbare Guides, wenn es brennt"). Der Tester nannte nur ein
Beispiel (Hospitalisierung) und bat, die Liste gemeinsam zu erarbeiten -
diese vier Situationen wurden mit dem Betreiber abgestimmt: ploetzliche
Hospitalisierung, Sturz/Notfall zuhause, Tod des Angehoerigen, Ausfall der
Pflegeperson selbst. Bewusst als schlichte, direkt abrufbare Schritt-fuer-
Schritt-Liste gehalten statt als langer Fliesstext, damit es im Ernstfall
schnell nutzbar ist - keine Rechtsberatung, ersetzt im Notfall nie den Anruf
bei 112/dem behandelnden Arzt."""
KRISENGUIDES = [
{
"id": "hospitalisierung",
"titel": "Plötzliche Hospitalisierung – was ist am ersten Tag zu tun?",
"kurz": "Die wichtigsten Schritte, wenn die gepflegte Person unerwartet ins Krankenhaus muss.",
"schritte": [
"Ruhe bewahren und wichtige Unterlagen zusammensuchen: Versichertenkarte, Medikamentenplan, Allergiepass, Patientenverfügung/Vorsorgevollmacht (falls vorhanden).",
"Die Pflegekasse über den Krankenhausaufenthalt informieren – wichtig für laufende Leistungen wie Verhinderungspflege oder Kurzzeitpflege.",
"Bei alleinlebenden Personen: Wohnung sichern (Fenster/Türen, Herd, verderbliche Lebensmittel, ggf. Nachsendeauftrag für die Post).",
"Haustiere, Pflanzen und laufende Termine (z. B. Pflegedienst) informieren bzw. vorübergehend pausieren.",
"Kontakt zum Sozialdienst des Krankenhauses aufnehmen – er organisiert die Anschlussversorgung (Reha, Kurzzeitpflege) und ist die wichtigste Anlaufstelle rund um die Entlassung.",
"Bei Verdacht, dass sich der Pflegebedarf dauerhaft verändert hat: einen Höherstufungsantrag bei der Pflegekasse vormerken – kann parallel zum Krankenhausaufenthalt gestellt werden.",
"Wichtige Kontakte (Hausarzt, Pflegedienst, weitere Angehörige) über den Krankenhausaufenthalt informieren.",
],
},
{
"id": "sturz_notfall",
"titel": "Sturz oder akuter Notfall zuhause",
"kurz": "Sofortmaßnahmen bei einem Sturz oder einem akuten gesundheitlichen Notfall.",
"schritte": [
"Ruhe bewahren – die Person bei Verdacht auf Fraktur oder Kopfverletzung möglichst nicht bewegen.",
"Bei Bewusstlosigkeit, starken Schmerzen, Blutungen oder Verdacht auf Schlaganfall/Herzinfarkt sofort den Notruf 112 wählen.",
"Wichtige Informationen für den Rettungsdienst bereithalten: Medikamentenplan, Vorerkrankungen, Allergien, Patientenverfügung.",
"Wohnungstür entriegeln bzw. kennzeichnen, damit der Rettungsdienst schnell hineingelangt.",
"Nach der Akutversorgung den Hausarzt bzw. behandelnden Arzt informieren.",
"Prüfen, ob vermeidbare Gefahrenquellen (Stolperfallen, schlechte Beleuchtung, ungeeignetes Schuhwerk) den Sturz begünstigt haben, und das Wohnumfeld entsprechend anpassen.",
"Falls der Vorfall zeigt, dass der Pflegebedarf gestiegen ist: einen Höherstufungsantrag bei der Pflegekasse in Erwägung ziehen.",
],
},
{
"id": "tod_angehoeriger",
"titel": "Tod des Angehörigen – die ersten Schritte",
"kurz": "Organisatorischer Überblick für die ersten Stunden und Tage nach dem Versterben.",
"schritte": [
"Einen Arzt verständigen, der den Tod feststellt und die Todesbescheinigung ausstellt (bei Tod zuhause: Hausarzt oder Notarzt).",
"Ein Bestattungsunternehmen beauftragen – es übernimmt meist die Überführung und viele weitere Formalitäten.",
"Die Sterbeurkunde beim Standesamt beantragen (oft durch den Bestatter) – wird für fast alle weiteren Schritte benötigt.",
"Die Kranken- und Pflegekasse informieren und die versicherte Person abmelden.",
"Rentenversicherungsträger informieren – laufende Zahlungen wie das Pflegegeld enden, ggf. Rückerstattungsansprüche prüfen.",
"Testament, Vorsorgevollmacht und Patientenverfügung prüfen; falls nötig das Nachlassgericht kontaktieren.",
"Laufende Verträge kündigen bzw. ummelden (Miete, Versicherungen, Abonnements, Bankvollmachten).",
"Angehörige, Freundeskreis und ggf. den Arbeitgeber informieren.",
"An sich selbst denken: Trauer braucht Zeit – Unterstützungsangebote (z. B. Hospizdienste, Seelsorge) sind keine Schwäche, sondern Hilfe.",
],
},
{
"id": "ausfall_pflegeperson",
"titel": "Ausfall der Pflegeperson – wenn Sie selbst plötzlich nicht können",
"kurz": "Was zu tun ist, wenn die pflegende Person selbst kurzfristig ausfällt (Krankheit, Unfall).",
"schritte": [
"Verhinderungspflege bei der Pflegekasse beantragen – bis zu 6 Wochen im Jahr, auch kurzfristig und rückwirkend möglich.",
"Familie, Freunde oder Nachbarn ansprechen, falls es bereits einen Notfallplan mit möglichen Vertretungen gibt.",
"Bei akutem, kurzfristigem Bedarf den örtlichen Pflegedienst kontaktieren, ob eine kurzfristige Übernahme möglich ist.",
"Falls die häusliche Versorgung gar nicht mehr möglich ist: Kurzzeitpflege in einer Einrichtung bei der Pflegekasse beantragen.",
"Den behandelnden Arzt bzw. Hausarzt der gepflegten Person informieren, insbesondere bei einem Medikamentenplan, der Aufmerksamkeit braucht.",
"Für die eigene Genesung sorgen – ein Ausfall ist oft auch ein Zeichen von Überlastung; Pflegestützpunkte beraten kostenlos zu langfristiger Entlastung.",
"Für die Zukunft vorsorgen: einen schriftlichen Notfallplan mit Kontakten, Medikamentenplan und Ablauf erstellen, damit ein erneuter Ausfall leichter zu überbrücken ist.",
],
},
]
@@ -0,0 +1,292 @@
"""Formulierungshilfen fuer die Patientenverfuegung, wortgetreu uebernommen
aus der offiziellen Broschuere "Patientenverfuegung" des Bundesministeriums
der Justiz (BMJ), Abschnitt 2 "Textbausteine Patientenverfuegung", Stand
Januar 2023 (bmj.de). Diese Textbausteine sind von der eingerichteten
Arbeitsgruppe "Patientenautonomie am Lebensende" ausdruecklich als
Formulierungshilfen zur woertlichen Uebernahme durch Buergerinnen und
Buerger in ihre eigene Patientenverfuegung gedacht - genau das ist der
Verwendungszweck hier. Als amtliches Werk (Buerger-Informationsmaterial
einer Bundesbehoerde) unterliegen sie nicht dem Urheberrechtsschutz im
Sinne des § 5 UrhG.
WICHTIG: Diese Zusammenstellung ersetzt keine rechtliche oder aerztliche
Beratung. Die generierte Patientenverfuegung ist - wie jede Patienten-
verfuegung - nur wirksam, wenn sie der tatsaechliche, freie Wille der
volljaehrigen, einwilligungsfaehigen Person ist (§ 1901a BGB); Software
kann diese Voraussetzungen nicht pruefen oder ersetzen.
"""
SITUATIONEN = [
{
"id": "sterbeprozess",
"label": "Ich befinde mich aller Wahrscheinlichkeit nach unabwendbar im unmittelbaren Sterbeprozess",
"text": "ich mich aller Wahrscheinlichkeit nach unabwendbar im unmittelbaren Sterbeprozess befinde",
},
{
"id": "endstadium",
"label": "Ich befinde mich im Endstadium einer unheilbaren, tödlich verlaufenden Krankheit",
"text": (
"ich mich im Endstadium einer unheilbaren, tödlich verlaufenden Krankheit befinde, "
"selbst wenn der Todeszeitpunkt noch nicht absehbar ist"
),
},
{
"id": "gehirnschaden",
"label": "Schwerster dauerhafter Gehirnschaden (z. B. durch Unfall, Schlaganfall, nach Wiederbelebung)",
"text": (
"infolge einer Gehirnschädigung meine Fähigkeit, Einsichten zu gewinnen, Entscheidungen "
"zu treffen und mit anderen Menschen in Kontakt zu treten, nach Einschätzung zweier "
"erfahrener Ärztinnen oder Ärzte aller Wahrscheinlichkeit nach unwiederbringlich erloschen "
"ist, selbst wenn der Todeszeitpunkt noch nicht absehbar ist. Dies gilt für direkte "
"Gehirnschädigung z. B. durch Unfall, Schlaganfall oder Entzündung ebenso wie für indirekte "
"Gehirnschädigung z. B. nach Wiederbelebung, Schock oder Lungenversagen. Es ist mir "
"bewusst, dass in solchen Situationen die Fähigkeit zu Empfindungen erhalten sein kann und "
"dass ein Aufwachen aus diesem Zustand nicht ganz sicher auszuschließen, aber unwahrscheinlich ist"
),
},
{
"id": "demenz",
"label": "Weit fortgeschrittener Hirnabbauprozess (z. B. bei Demenzerkrankung)",
"text": (
"ich infolge eines weit fortgeschrittenen Hirnabbauprozesses (z. B. bei Demenzerkrankung) "
"auch mit ausdauernder Hilfestellung nicht mehr in der Lage bin, Nahrung und Flüssigkeit "
"auf natürliche Weise zu mir zu nehmen"
),
},
]
# Jede Massnahme hat "optionen" (sich gegenseitig ausschliessend, im
# Original durch "oder" verbunden) und optional "zusatz" (ein zusaetzlicher,
# unabhaengig ankreuzbarer Baustein, im Original ohne "oder" als eigener
# Punkt aufgefuehrt).
MASSNAHMEN = {
"lebenserhaltung": {
"label": "Lebenserhaltende Maßnahmen",
"einleitung": "In den oben beschriebenen Situationen wünsche ich,",
"optionen": [
{
"id": "alles_tun",
"label": "Alles medizinisch Mögliche und Sinnvolle tun, um mich am Leben zu erhalten",
"text": "dass alles medizinisch Mögliche und Sinnvolle getan wird, um mich am Leben zu erhalten",
},
{
"id": "unterlassen",
"label": "Alle lebenserhaltenden Maßnahmen unterlassen (nur Grundpflege und Symptomlinderung)",
"text": (
"dass alle lebenserhaltenden Maßnahmen unterlassen werden. Hunger und Durst sollen "
"auf natürliche Weise gestillt werden, gegebenenfalls mit Hilfe bei der Nahrungs- und "
"Flüssigkeitsaufnahme. Ich wünsche fachgerechte Pflege von Mund und Schleimhäuten "
"sowie menschenwürdige Unterbringung, Zuwendung, Körperpflege und das Lindern von "
"Schmerzen, Atemnot, Übelkeit, Angst, Unruhe und anderer belastender Symptome"
),
},
],
},
"schmerzbehandlung": {
"label": "Schmerz- und Symptombehandlung",
"einleitung": "In den oben beschriebenen Situationen wünsche ich eine fachgerechte Schmerz- und Symptombehandlung,",
"optionen": [
{
"id": "ohne_daempfung",
"label": "Aber ohne bewusstseinsdämpfende Wirkungen",
"text": "aber ohne bewusstseinsdämpfende Wirkungen",
},
{
"id": "auch_mit_daempfung",
"label": "Notfalls auch mit bewusstseinsdämpfenden Mitteln zur Beschwerdelinderung",
"text": (
"wenn alle sonstigen medizinischen Möglichkeiten zur Schmerz- und Symptomkontrolle "
"versagen, auch Mittel mit bewusstseinsdämpfenden Wirkungen zur Beschwerdelinderung"
),
},
],
"zusatz": {
"id": "verkuerzung_akzeptieren",
"label": "Eine dadurch unwahrscheinliche Verkürzung meiner Lebenszeit nehme ich in Kauf",
"text": (
"die unwahrscheinliche Möglichkeit einer ungewollten Verkürzung meiner Lebenszeit durch "
"schmerz- und symptomlindernde Maßnahmen nehme ich in Kauf"
),
},
},
"ernaehrung": {
"label": "Künstliche Ernährung und Flüssigkeitszufuhr",
"einleitung": "In den oben beschriebenen Situationen wünsche ich,",
"optionen": [
{
"id": "fortfuehren",
"label": "Fortführen/beginnen, wenn dies mein Leben verlängern kann",
"text": (
"dass eine künstliche Ernährung und Flüssigkeitszufuhr begonnen oder weitergeführt "
"wird, wenn damit mein Leben verlängert werden kann"
),
},
{
"id": "nur_palliativ",
"label": "Nur bei palliativmedizinischer Indikation zur Beschwerdelinderung",
"text": (
"dass eine künstliche Ernährung und/oder eine künstliche Flüssigkeitszufuhr nur bei "
"palliativmedizinischer Indikation zur Beschwerdelinderung erfolgen bzw. erfolgt"
),
},
{
"id": "keine",
"label": "Keine künstliche Ernährung und keine künstliche Flüssigkeitszufuhr",
"text": (
"dass keine künstliche Ernährung unabhängig von der Form der künstlichen Zuführung "
"der Nahrung (z. B. Magensonde durch Mund, Nase oder Bauchdecke, venöse Zugänge) und "
"keine künstliche Flüssigkeitszufuhr erfolgen"
),
},
],
},
"beatmung": {
"label": "Künstliche Beatmung",
"einleitung": "In den oben beschriebenen Situationen wünsche ich",
"optionen": [
{
"id": "ja",
"label": "Künstliche Beatmung, falls dies mein Leben verlängern kann",
"text": "eine künstliche Beatmung, falls dies mein Leben verlängern kann",
},
{
"id": "nein",
"label": "Keine künstliche Beatmung (bzw. Beendigung), mit Linderung der Luftnot",
"text": (
"dass keine künstliche Beatmung durchgeführt bzw. eine schon eingeleitete Beatmung "
"eingestellt wird, unter der Voraussetzung, dass ich Medikamente zur Linderung der "
"Luftnot erhalte. Die Möglichkeit einer Bewusstseinsdämpfung oder einer ungewollten "
"Verkürzung meiner Lebenszeit durch diese Medikamente nehme ich in Kauf"
),
},
],
},
"dialyse": {
"label": "Dialyse (künstliche Blutwäsche)",
"einleitung": "In den oben beschriebenen Situationen wünsche ich",
"optionen": [
{
"id": "ja",
"label": "Dialyse, falls dies mein Leben verlängern kann",
"text": "eine künstliche Blutwäsche (Dialyse), falls dies mein Leben verlängern kann",
},
{
"id": "nein",
"label": "Keine Dialyse (bzw. Beendigung einer laufenden Dialyse)",
"text": "dass keine Dialyse durchgeführt bzw. eine schon eingeleitete Dialyse eingestellt wird",
},
],
},
"antibiotika": {
"label": "Antibiotika",
"einleitung": "In den oben beschriebenen Situationen wünsche ich",
"optionen": [
{
"id": "ja",
"label": "Antibiotika, falls dies mein Leben verlängern kann",
"text": "Antibiotika, falls dies mein Leben verlängern kann",
},
{
"id": "nur_palliativ",
"label": "Nur bei palliativmedizinischer Indikation zur Beschwerdelinderung",
"text": "Antibiotika nur bei palliativmedizinischer Indikation zur Beschwerdelinderung",
},
{
"id": "nein",
"label": "Keine Antibiotika",
"text": "keine Antibiotika",
},
],
},
"blut": {
"label": "Blut / Blutbestandteile",
"einleitung": "In den oben beschriebenen Situationen wünsche ich",
"optionen": [
{
"id": "ja",
"label": "Gabe von Blut/Blutbestandteilen, falls dies mein Leben verlängern kann",
"text": "die Gabe von Blut oder Blutbestandteilen, falls dies mein Leben verlängern kann",
},
{
"id": "nur_palliativ",
"label": "Nur bei palliativmedizinischer Indikation zur Beschwerdelinderung",
"text": (
"die Gabe von Blut oder Blutbestandteilen nur bei palliativmedizinischer Indikation "
"zur Beschwerdelinderung"
),
},
{
"id": "nein",
"label": "Keine Gabe von Blut oder Blutbestandteilen",
"text": "keine Gabe von Blut oder Blutbestandteilen",
},
],
},
}
WIEDERBELEBUNG_A_OPTIONEN = [
{
"id": "ja",
"label": "Versuche der Wiederbelebung",
"text": "Versuche der Wiederbelebung",
},
{
"id": "nein",
"label": "Unterlassung von Versuchen der Wiederbelebung",
"text": "die Unterlassung von Versuchen der Wiederbelebung",
"zusatz_text": (
"dass eine Notärztin oder ein Notarzt nicht verständigt wird bzw. im Fall einer Hinzuziehung "
"unverzüglich über meine Ablehnung von Wiederbelebungsmaßnahmen informiert wird"
),
},
]
WIEDERBELEBUNG_B_OPTIONEN = [
{"id": "keine_regelung", "label": "Keine zusätzliche, situationsunabhängige Regelung"},
{
"id": "immer_ablehnen",
"label": "Wiederbelebungsmaßnahmen in allen Fällen ablehnen (auch außerhalb der oben genannten Situationen)",
"text": "lehne ich Wiederbelebungsmaßnahmen ab",
},
{
"id": "ablehnen_ausser_aerztlich",
"label": "Ablehnen, außer bei unerwartetem Kreislaufstillstand im Rahmen einer ärztlichen Maßnahme (z. B. Operation)",
"text": (
"lehne ich Wiederbelebungsmaßnahmen ab, sofern diese Situationen nicht im Rahmen ärztlicher "
"Maßnahmen (z. B. Operationen) unerwartet eintreten"
),
},
]
ORT_OPTIONEN = [
{"id": "krankenhaus", "label": "Zum Sterben ins Krankenhaus verlegt werden", "text": "zum Sterben ins Krankenhaus verlegt werden"},
{"id": "zuhause", "label": "Wenn möglich zu Hause bzw. in vertrauter Umgebung sterben", "text": "wenn möglich zu Hause bzw. in vertrauter Umgebung sterben"},
{"id": "hospiz", "label": "Wenn möglich in einem Hospiz sterben", "text": "wenn möglich in einem Hospiz sterben"},
{"id": "keine_angabe", "label": "Keine Angabe"},
]
VERTRAUENSPERSON_OPTIONEN = [
{"id": "bevollmaechtigter", "label": "Meiner/meinem Bevollmächtigten", "text": "meiner/meinem Bevollmächtigten"},
{"id": "betreuer", "label": "Meiner Betreuerin/meinem Betreuer", "text": "meiner Betreuerin/meinem Betreuer"},
{"id": "arzt", "label": "Der behandelnden Ärztin oder dem behandelnden Arzt", "text": "der behandelnden Ärztin oder dem behandelnden Arzt"},
{"id": "andere", "label": "Einer anderen Person (Name eintragen)", "text": "anderer Person"},
]
ORGANSPENDE_OPTIONEN = [
{"id": "zustimmung", "label": "Ich stimme einer Organentnahme nach meinem Tod zu Transplantationszwecken zu"},
{"id": "ablehnung", "label": "Ich lehne eine Organentnahme nach meinem Tod ab"},
{"id": "keine_angabe", "label": "Keine Angabe"},
]
ORGANSPENDE_KONFLIKT_OPTIONEN = [
{
"id": "organspende_vor",
"label": "Die Organspende-Bereitschaft geht vor",
"text": "geht die von mir erklärte Bereitschaft zur Organspende vor",
},
{
"id": "patientenverfuegung_vor",
"label": "Die Bestimmungen der Patientenverfügung gehen vor",
"text": "gehen die Bestimmungen in meiner Patientenverfügung vor",
},
]
@@ -0,0 +1,14 @@
"""Pflegepauschbetrag nach § 33b Abs. 6 EStG (Tester-Feedback: "schreibe den
Steuervorteil der Eigenleistung hinzu"). Recherchiert Stand 2026: 600 € ab
Pflegegrad 2, 1.100 € ab Pflegegrad 3, 1.800 € ab Pflegegrad 4/5 - Grad 0/1
haben keinen Anspruch. Voraussetzung laut Gesetz: die Pflege wird persoenlich
im haeuslichen Umfeld und unentgeltlich (kein Entgelt ueber ein ggf.
weitergeleitetes Pflegegeld hinaus) erbracht - genau der Fall, den eine als
"eigenleistung" markierte Leistung hier abbildet. KEINE Steuerberatung, nur
ein Hinweis - im Einzelfall pruefen/mit Steuerberater abstimmen."""
PFLEGEPAUSCHBETRAG = {0: 0, 1: 0, 2: 600, 3: 1100, 4: 1800, 5: 1800}
def pflegepauschbetrag_fuer(pflegegrad: int) -> int:
return PFLEGEPAUSCHBETRAG.get(pflegegrad, 0)
@@ -0,0 +1 @@
{"01054":"Sächsische Aufbaubank","01057":"HUK-Coburg","01067":"Dresden","01069":"Dresden","01095":"Sächsische Staatskanzlei","01097":"Dresden","01099":"Dresden","01108":"Dresden","01109":"Dresden","01127":"Dresden","01129":"Dresden","01139":"Dresden","01156":"Dresden","01157":"Dresden","01159":"Dresden","01169":"Dresden","01187":"Dresden","01189":"Dresden","01217":"Dresden","01219":"Dresden","01237":"Dresden","01239":"Dresden","01257":"Dresden","01259":"Dresden","01277":"Dresden","01279":"Dresden","01307":"Dresden","01309":"Dresden","01324":"Dresden","01326":"Dresden","01328":"Dresden","01332":"Amazon FC WRO2","01445":"Radebeul","01454":"Wachau","01458":"Ottendorf-Okrilla","01465":"Dresden","01468":"Moritzburg","01471":"Radeburg","01477":"Arnsdorf","01558":"Großenhain","01561":"Tauscha","01587":"Riesa","01589":"Riesa","01591":"Riesa","01594":"Stauchitz","01609":"Gröditz","01612":"Glaubitz","01616":"Strehla","01619":"Röderaue","01623":"Lommatzsch","01640":"Coswig","01662":"Meißen","01665":"Taubenheim","01683":"Nossen","01689":"Niederau","01705":"Freital","01723":"Wilsdruff","01728":"Bannewitz","01730":"Klinik Bavaria","01731":"Kreischa","01734":"Rabenau","01737":"Tharandt","01738":"Dorfhain","01744":"Dippoldiswalde","01762":"Schmiedeberg","01768":"Glashütte","01773":"Altenberg","01774":"Höckendorf","01776":"Hermsdorf/Erzgebirge","01778":"Geising","01796":"Dohma","01809":"Dohna","01814":"Porschdorf","01816":"Bad Gottleuba-Berggießhübel","01819":"Bahretal","01824":"Rathen","01825":"Liebstadt","01829":"Stadt Wehlen","01833":"Stolpen","01844":"Hohwald","01847":"Lohmen","01848":"Hohnstein","01855":"Sebnitz","01877":"Rammenau","01896":"Ohorn","01900":"Großröhrsdorf","01904":"Neukirch/Lausitz","01906":"Burkau","01909":"Frankenthal","01917":"Kamenz","01920":"Elstra","01936":"Laußnitz","01945":"Tettau","01968":"Schipkau","01979":"Lauchhammer","01983":"Großräschen","01987":"Schwarzheide","01990":"Ortrand","01993":"Schipkau","01994":"Schipkau","01996":"Hosena","01998":"Schipkau","02620":"Landratsamt Bautzen","02625":"Bautzen","02627":"Radibor","02633":"Göda","02681":"Crostau","02689":"Sohland an der Spree","02692":"Eulowitz","02694":"Guttau","02699":"Neschwitz","02708":"Löbau","02727":"Neugersdorf","02730":"Ebersbach/Sachsen","02733":"Cunewalde","02736":"Oppach","02739":"Eibau","02742":"Friedersdorf","02747":"Herrnhut","02748":"Bernstadt auf dem Eigen","02763":"Zittau","02779":"Hainewalde","02782":"Seifhennersdorf","02785":"Olbersdorf","02788":"Schlegel","02791":"Oderwitz","02794":"Leutersdorf","02796":"Jonsdorf","02797":"Oybin","02799":"Waltersdorf","02826":"Görlitz","02827":"Görlitz","02828":"Görlitz","02829":"Neißeaue","02894":"Sohland am Rotstein","02899":"Ostritz","02906":"Mücka","02923":"Horka","02929":"Rothenburg/Oberlausitz","02943":"Weißwasser","02953":"Gablenz","02956":"Rietschen","02957":"Weißkeißel","02959":"Schleife","02977":"Hoyerswerda","02979":"Spreetal","02991":"Lauta","02994":"Wiednitz","02997":"Wittichenau","02999":"Lohsa","03042":"Cottbus","03044":"Cottbus","03046":"Cottbus","03048":"Cottbus","03050":"Cottbus","03051":"Cottbus","03052":"Cottbus","03053":"Cottbus","03054":"Cottbus","03055":"Cottbus","03058":"Haasow","03096":"Guhrow","03099":"Kolkwitz","03103":"Neu-Seeland","03116":"Drebkau","03119":"Welzow","03130":"Bagenz","03139":"Terpe","03149":"Wiesengrund","03159":"Döbern","03172":"Guben","03185":"Peitz","03197":"Drewitz","03202":"Luckow-Petershagen","03205":"Lug","03222":"Ragow","03226":"Leipe","03229":"Altdöbern","03238":"Lugau","03246":"Gahro","03249":"Bahren","03253":"Brenitz","04022":"Sächsische Aufbaubank","04092":"Stadt Leipzig Neues Rathaus","04097":"HUK-Coburg","04103":"Leipzig","04105":"Leipzig","04107":"Leipzig","04109":"Leipzig","04129":"Leipzig","04155":"Leipzig","04157":"Leipzig","04158":"Breitenfeld","04159":"Leipzig","04177":"Leipzig","04178":"Burghausen","04179":"Leipzig","04205":"Leipzig","04207":"Leipzig","04209":"Leipzig","04227":"Mey & Edlich","04229":"Leipzig","04249":"Leipzig","04275":"Leipzig","04277":"Leipzig","04279":"Leipzig","04288":"Leipzig","04289":"Leipzig","04299":"Leipzig","04315":"Leipzig","04316":"Leipzig","04317":"Leipzig","04318":"Leipzig","04319":"Leipzig","04328":"Sellerhausen-Stünz","04329":"Leipzig","04347":"Leipzig","04349":"Leipzig","04350":"BMW","04356":"Leipzig","04357":"Leipzig","04370":"Deutsche Post DHL","04416":"Markkleeberg","04420":"Großlehna","04425":"Taucha","04435":"Schkeuditz","04442":"Zwenkau","04451":"Borsdorf","04460":"Kitzen","04463":"Großpösna","04509":"Löbnitz","04519":"Rackwitz","04523":"Pegau","04539":"Groitzsch","04550":"Landkreis Leipzig","04552":"Borna","04564":"Böhlen","04565":"Regis-Breitingen","04567":"Kitzscher","04571":"Rötha","04574":"Deutzen","04575":"Neukieritzsch","04579":"Espenhain","04600":"Altenburg","04603":"Saara","04610":"Meuselwitz","04613":"Lucka","04617":"Lödla","04618":"Frohnsdorf","04626":"Mehna","04639":"Gößnitz","04643":"Geithain","04651":"Eulatal","04654":"Frohburg","04Line truncated
@@ -0,0 +1,35 @@
"""PLZ->Ort-Nachschlagewerk fuer die Adress-Autofill-Funktion (Nutzer- und
Patienten-Anlage). Datenbasis: GeoNames.org Postal-Code-Export fuer
Deutschland (DE.zip, CC BY 4.0, https://download.geonames.org/export/zip/),
Stand 2026-07-28 heruntergeladen.
Deutschlands ca. 10.800 vergebene PLZ umfassen nicht nur normale
Wohnadressen, sondern auch ca. 1.800-2.000 sogenannte Grosskunden-PLZ
(dedizierte Postleitzahlen fuer einzelne Firmen/Behoerden mit hohem
Postaufkommen, z.B. "10875" ausschliesslich fuer Mercedes-Benz-Gesellschaften
in Stuttgart) - bei diesen enthaelt GeoNames' Ortsnamen-Feld den
Firmen-/Behoerdennamen statt eines echten Orts. Diese wurden beim Aufbau
dieser Datei ueber eine Firmennamen-Erkennung (Rechtsformen wie GmbH/AG/KG,
Behoerdenbegriffe wie Finanzamt/Ministerium, Kassen/Verbaende etc.) so weit
moeglich herausgefiltert - keine 100%ige Genauigkeit (vereinzelte reine
Regierungssitz-PLZ ohne echten Ortsnamen-Kandidaten koennen noch falsch
zugeordnet sein), aber fuer eine Autofill-Komfortfunktion ausreichend: die
Nutzer koennen den Vorschlag jederzeit ueberschreiben.
Bei PLZ, die sich mehrere Ortsteile/Nachbargemeinden teilen (z.B. laendliche
PLZ, die mehrere kleine Gemeinden abdecken), wurde die kuerzeste der
verbleibenden Kandidaten-Bezeichnungen gewaehlt (meist der schlichte
Ortsname ohne Ortsteil-Zusatz) - kein Mehrfachauswahl-UI, da fuer eine
einfache Autofill-Konvenienzfunktion nicht gerechtfertigt.
"""
import json
from pathlib import Path
_PFAD = Path(__file__).parent / "plz_orte.json"
with open(_PFAD, encoding="utf-8") as _f:
PLZ_ORTE: dict[str, str] = json.load(_f)
def get_ort(plz: str) -> str | None:
return PLZ_ORTE.get(plz)
@@ -1,5 +1,5 @@
"""
Pflegepilot – Stammdaten der Deutschen Rentenversicherung (Bund + regionale
MeinPflegeFuchs – Stammdaten der Deutschen Rentenversicherung (Bund + regionale
Traeger). Adressen Stand 2026, Quelle: deutsche-rentenversicherung.de
"Anschriften-Uebersicht". Vor Verwendung bitte auf Aktualitaet pruefen.
@@ -0,0 +1,227 @@
"""Erkennt Aenderungen bei gemerkten Umkreissuche-Favoriten (Name/Adresse/
Telefon/E-Mail/Webseite) und benachrichtigt den Kontoinhaber per E-Mail
(Tester-Feedback T15, siehe Feedbackfromtester.md - Nachfolge zu T10-T13).
Wie T14 (katalog_watch.py) eine bewusste Ausnahme vom "mailto statt SMTP"-
Prinzip: es gibt keine Nutzeraktion, an die sich der Versand haengen liesse,
die Aenderung kann nur der Server selbst erkennen.
Anders als katalog_watch.py (laeuft einmalig pro App-Start) braucht dies
einen echten wiederkehrenden Hintergrundjob wie reminder_scheduler.py -
Favoriten koennen sich in OSM jederzeit aendern, nicht nur bei einem Deploy
dieser App. Laeuft bewusst nur EINMAL TAEGLICH (nicht haeufiger): der
oeffentliche Overpass-Mirror hat ein sehr knappes Rate-Limit (real erlebt
waehrend der Entwicklung dieses Features: nur 2 gleichzeitige Slots pro IP,
siehe CLAUDE.md-Betriebshinweis zur Umkreissuche) - eine haeufigere Abfrage
aller Favoriten aller Konten waere unverhaeltnismaessig fuer eine
gemeinschaftlich betriebene, kostenlose Infrastruktur.
Ausserdem werden alle Favoriten EINES osm_type (node/way/relation) in
moeglichst wenigen Sammel-Queries per Overpass-ID-Filter (`node(id:1,2,3);`,
siehe OSM-Wiki "Overpass QL - By element ID") abgefragt statt einer Anfrage
je Favorit - bei z.B. 50 gemerkten Favoriten waeren das sonst 50 statt 1-3
Anfragen pro Durchlauf.
Bekannte, bewusste Einschraenkung (analog zu den Datenlage-Luecken bei
Umkreissuche-Kategorien): ist ein Favorit in OSM komplett nicht mehr
auffindbar (geloescht), wird das nur geloggt, nicht dem Nutzer gemeldet -
ein "verschwunden statt geaendert"-Notify-Flow braeuchte eine zusaetzliche
Dedupe-Schutzmassnahme gegen taegliche Wiederholungsmeldungen (z.B. ein
neues Schema-Feld oder automatisches Loeschen des Favoriten), was fuer den
urspruenglichen Tester-Wunsch ("Kontaktdaten geaendert") ueberkonstruiert
waere - bewusst nicht gebaut, siehe CLAUDE.md.
Gunicorn-Multi-Worker-Problem/-Loesung identisch zu reminder_scheduler.py:
Postgres-Advisory-Lock ueber die gesamte Thread-Lebensdauer."""
import logging
import threading
import time
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal, engine
from app.email_util import send_email
from app.models import Nutzer, UmkreissucheFavorit
from app.umkreissuche import OVERPASS_URL, USER_AGENT
logger = logging.getLogger(__name__)
_ADVISORY_LOCK_ID = 519274863
_PRUEF_INTERVALL_SEKUNDEN = 86400 # einmal taeglich - siehe Modul-Docstring
_ID_CHUNK_GROESSE = 100 # pro Sammel-Query, gegen zu lange Overpass-Queries
_PAUSE_ZWISCHEN_CHUNKS_SEKUNDEN = 2.0 # Ruecksicht auf den oeffentlichen Mirror
_VERGLICHENE_FELDER = ("name", "strasse", "plz", "ort", "telefon", "email", "webseite")
_FELD_LABEL = {
"name": "Name",
"strasse": "Straße",
"plz": "PLZ",
"ort": "Ort",
"telefon": "Telefon",
"email": "E-Mail",
"webseite": "Webseite",
}
def starte_umkreis_favoriten_scheduler() -> None:
thread = threading.Thread(target=_scheduler_thread, name="umkreis-favoriten-watch", daemon=True)
thread.start()
def _scheduler_thread() -> None:
# Eigene, dauerhaft offene Connection nur fuer den Advisory-Lock - der
# Lock ist an die Connection gebunden und wird automatisch freigegeben,
# sobald sie schliesst oder der Prozess stirbt.
lock_connection = engine.connect()
got_lock = lock_connection.execute(
text("SELECT pg_try_advisory_lock(:id)"), {"id": _ADVISORY_LOCK_ID}
).scalar()
if not got_lock:
logger.info(
"Umkreis-Favoriten-Watch laeuft schon in einem anderen Worker - dieser Thread beendet sich."
)
lock_connection.close()
return
logger.info("Umkreis-Favoriten-Watch gestartet (prueft alle %s Sekunden).", _PRUEF_INTERVALL_SEKUNDEN)
try:
while True:
try:
_pruefe_alle_favoriten()
except Exception:
logger.exception("Fehler im Umkreis-Favoriten-Watch-Durchlauf")
time.sleep(_PRUEF_INTERVALL_SEKUNDEN)
finally:
lock_connection.close()
def _chunked(seq: list, groesse: int):
for i in range(0, len(seq), groesse):
yield seq[i : i + groesse]
def _elemente_abrufen(osm_type: str, ids: list[int]) -> dict[int, dict]:
"""Fragt mehrere OSM-Elemente desselben Typs in moeglichst wenigen
Sammel-Anfragen ab (Overpass-ID-Filter), statt einer Anfrage je
Favorit."""
ergebnis: dict[int, dict] = {}
chunks = list(_chunked(ids, _ID_CHUNK_GROESSE))
for index, chunk in enumerate(chunks):
if index:
time.sleep(_PAUSE_ZWISCHEN_CHUNKS_SEKUNDEN)
id_liste = ",".join(str(osm_id) for osm_id in chunk)
query = f"[out:json][timeout:25];{osm_type}(id:{id_liste});out tags;"
try:
resp = httpx.post(
OVERPASS_URL,
data={"data": query},
headers={"User-Agent": USER_AGENT},
timeout=30.0,
)
resp.raise_for_status()
for el in resp.json().get("elements", []):
ergebnis[el["id"]] = el.get("tags", {})
except httpx.HTTPError:
# Bewusst kein Retry hier (anders als bei der interaktiven
# Umkreissuche) - ein Fehlschlag heute wird beim naechsten
# taeglichen Durchlauf automatisch erneut versucht, ein
# sofortiger Retry wuerde nur unnoetig Last auf dem knappen
# oeffentlichen Mirror erzeugen.
logger.warning(
"Umkreis-Favoriten-Watch: Overpass-Abfrage fuer %s-Chunk (%d IDs) fehlgeschlagen",
osm_type,
len(chunk),
)
return ergebnis
def _aus_tags(tags: dict) -> dict:
strasse = tags.get("addr:street")
hausnr = tags.get("addr:housenumber")
if strasse and hausnr:
strasse = f"{strasse} {hausnr}"
return {
"name": tags.get("name"),
"strasse": strasse,
"plz": tags.get("addr:postcode"),
"ort": tags.get("addr:city"),
"telefon": tags.get("contact:phone") or tags.get("phone"),
"email": tags.get("contact:email") or tags.get("email"),
"webseite": tags.get("contact:website") or tags.get("website"),
}
def _pruefe_alle_favoriten() -> None:
db = SessionLocal()
try:
favoriten = db.query(UmkreissucheFavorit).all()
if not favoriten:
return
nach_typ: dict[str, list[UmkreissucheFavorit]] = {}
for f in favoriten:
nach_typ.setdefault(f.osm_type, []).append(f)
aenderungen_je_nutzer: dict[int, list[tuple[UmkreissucheFavorit, dict]]] = {}
for osm_type, gruppe in nach_typ.items():
aktuelle = _elemente_abrufen(osm_type, [f.osm_id for f in gruppe])
for favorit in gruppe:
tags = aktuelle.get(favorit.osm_id)
if tags is None:
logger.info(
"Umkreis-Favoriten-Watch: %s/%s nicht mehr in OSM auffindbar",
favorit.osm_type,
favorit.osm_id,
)
continue
neue_werte = _aus_tags(tags)
abweichungen = {
feld: (getattr(favorit, feld), neue_werte[feld])
for feld in _VERGLICHENE_FELDER
if neue_werte[feld] and getattr(favorit, feld) != neue_werte[feld]
}
if abweichungen:
aenderungen_je_nutzer.setdefault(favorit.nutzer_id, []).append((favorit, abweichungen))
for feld, (_, neu) in abweichungen.items():
setattr(favorit, feld, neu)
for nutzer_id, eintraege in aenderungen_je_nutzer.items():
_benachrichtige_nutzer(db, nutzer_id, eintraege)
db.commit()
finally:
db.close()
def _benachrichtige_nutzer(
db: Session, nutzer_id: int, eintraege: list[tuple[UmkreissucheFavorit, dict]]
) -> None:
nutzer = db.get(Nutzer, nutzer_id)
if not nutzer or not nutzer.email:
return
zeilen = [
"Hallo,",
"",
"bei folgenden von Ihnen gemerkten Einrichtungen aus der Umkreissuche haben "
"sich die Kontaktdaten geändert:",
"",
]
for favorit, abweichungen in eintraege:
zeilen.append(f"„{favorit.name}“:")
for feld, (alt, neu) in abweichungen.items():
zeilen.append(f" {_FELD_LABEL[feld]}: {alt or '–'} → {neu}")
zeilen.append("")
zeilen += [
"Bitte prüfen Sie die Angaben in Ihren Umkreissuche-Favoriten in MeinPflegeFuchs.",
"",
"Erstellt mit MeinPflegeFuchs.",
]
send_email(
nutzer.email,
"MeinPflegeFuchs – Änderung bei gemerkten Einrichtungen",
"\n".join(zeilen),
)
+224
View File
@@ -0,0 +1,224 @@
"""Umkreissuche fuer lokale Pflege-Dienstleister ueber OpenStreetMap (kostenlos,
kein API-Key noetig - bewusste Entscheidung gegen Google Places wegen laufender
Kosten, siehe Feedbackfromtester.md T10). Zwei externe OSM-Dienste sind
beteiligt: Nominatim (PLZ -> Koordinaten) und Overpass (Umkreissuche nach
getaggten Einrichtungen). Beide sind offene Community-Infrastruktur ohne
Account/Key - dafuer gilt Nominatims Nutzungsrichtlinie (max. 1 Anfrage/s,
aussagekraeftiger User-Agent), die durch das nutzerausgeloeste Anfragemuster
dieser App von selbst eingehalten wird.
KATEGORIEN ist bewusst eine Registry statt einer Kette von if/elif - T10 legt
hier nur "pflegedienst" an, T11-T13 (Sanitaetshaus, Wohnformen, Therapie/Reha)
ergaenzen laut Feedbackfromtester.md nur weitere Eintraege auf derselben
Infrastruktur, ohne Suchlogik/Router anzufassen.
"""
import math
import time
import httpx
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
USER_AGENT = "MeinPflegeFuchs/1.0 (https://meinpflegefuchs.de; kontakt@meinpflegefuchs.de)"
# Overpass-Tag-Filter je Kategorie, recherchiert gegen OSM-Wiki (DE:Tag:...)
# und taginfo.openstreetmap.org - siehe CLAUDE.md-Abschnitt zu T10-T13 fuer
# die Herleitung und bekannte Luecken/Unsicherheiten je Kategorie. "filters"
# ist eine Liste statt eines einzelnen Strings, weil mehrere Kategorien (z.B.
# Pflegeheim) sowohl das aktuelle als auch ein noch verbreitetes, aber laut
# Wiki veraltetes Tag-Schema abdecken muessen, um migrierte wie
# nicht-migrierte OSM-Datensaetze gleichermassen zu finden.
KATEGORIEN: dict[str, dict] = {
"pflegedienst": {
"label": "Ambulanter Pflegedienst",
"filters": ['["amenity"="social_facility"]["social_facility"="ambulatory_care"]'],
},
"pflegedienst_24h": {
# Kein eigenes OSM-Tag fuer "24-Stunden-Pflege"/Betreuung rund um
# die Uhr recherchierbar (OSM-Wiki: waere technisch
# ambulatory_care + opening_hours=24/7, aber opening_hours wird bei
# Pflegediensten in der Praxis so gut wie nie gepflegt - eine reine
# Tag-Suche darauf liefert praktisch keine Treffer). Realistischerer
# Proxy: 24h-Anbieter/-Vermittler tragen "24h"/"24-Stunden" in
# aller Regel im eigenen Firmennamen (Branchenusus) - deshalb ein
# Namens-Musterfilter auf denselben ambulatory_care-Treffern statt
# eines Tag-Filters. Gleiche Kompromiss-Logik wie bei "Soziales
# Cafe" (T12): transparent im Label markiert statt vorgetaeuschter
# Vollstaendigkeit.
"label": "24h-Pflegedienst (geringe Datenlage)",
"filters": [
'["amenity"="social_facility"]["social_facility"="ambulatory_care"]["name"~"24.?h|24.?stunden",i]'
],
},
"sanitaetshaus": {
"label": "Sanitätshaus",
"filters": ['["shop"="medical_supply"]'],
},
"pflegeheim": {
"label": "Pflegeheim",
"filters": [
'["amenity"="social_facility"]["social_facility"="nursing_home"]',
'["amenity"="nursing_home"]',
],
},
"betreutes_wohnen": {
"label": "Betreutes Wohnen / Seniorenheim",
"filters": [
'["amenity"="social_facility"]["social_facility"="assisted_living"]',
'["amenity"="retirement_home"]',
],
},
"tagespflege": {
"label": "Tagespflege",
"filters": ['["amenity"="social_facility"]["social_facility"="day_care"]'],
},
"demenzgruppe": {
"label": "Demenz-Wohngruppe",
"filters": [
'["amenity"="social_facility"]["social_facility"="group_home"]["social_facility:for"="dementia"]'
],
},
"soziales_cafe": {
# Kein dediziertes OSM-Tag fuer "Soziales Cafe" vorhanden (siehe
# Rechercheergebnis zu T12) - amenity=community_centre ist der beste
# verfuegbare Näherungswert, deutlich geringere Trefferquote/-praezision
# als die anderen Kategorien, deshalb im Label transparent markiert.
"label": "Soziales Café (geringe Datenlage)",
"filters": ['["amenity"="community_centre"]'],
},
"logopaedie": {
"label": "Logopädie",
"filters": ['["healthcare"="speech_therapist"]'],
},
"physiotherapie": {
"label": "Physiotherapie",
"filters": ['["healthcare"="physiotherapist"]'],
},
"reha_zentrum": {
"label": "Reha-Zentrum",
"filters": ['["healthcare"="rehabilitation"]'],
},
# Reha-Sport bewusst nicht als eigene Kategorie: es gibt kein
# verlaessliches OSM-Tag dafuer (Kurse finden meist in normalen
# Sportvereinen/Fitnessstudios statt, ohne eigenen kartierbaren Ort) -
# siehe Feedbackfromtester.md T13 fuer die Herleitung. Eine Kategorie
# ohne verlaessliche Trefferquote vorzugaukeln waere schlechter als sie
# wegzulassen.
}
class UmkreissucheFehler(Exception):
"""Externe OSM-Dienste nicht erreichbar/Timeout - vom Router in ein
sauberes 502 statt eines rohen 500-Crashs uebersetzt."""
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _geocode_plz(plz: str) -> tuple[float, float]:
try:
resp = httpx.get(
NOMINATIM_URL,
params={"postalcode": plz, "country": "Germany", "format": "json", "limit": 1},
headers={"User-Agent": USER_AGENT},
timeout=10.0,
)
resp.raise_for_status()
treffer = resp.json()
except httpx.HTTPError as e:
raise UmkreissucheFehler("Geocoding-Dienst nicht erreichbar") from e
if not treffer:
raise UmkreissucheFehler(f"PLZ {plz} konnte nicht aufgeloest werden")
return float(treffer[0]["lat"]), float(treffer[0]["lon"])
def _overpass_suche(filter_clauses: list[str], lat: float, lon: float, radius_m: int) -> list[dict]:
statements = "\n ".join(
f"nwr{clause}(around:{radius_m},{lat},{lon});" for clause in filter_clauses
)
query = f"""
[out:json][timeout:25];
(
{statements}
);
out center tags;
"""
# Der oeffentliche Overpass-Mirror ist gemeinschaftlich betrieben und
# antwortet bei kurz aufeinanderfolgenden Anfragen (z.B. mehrere
# Kategorien schnell hintereinander) gelegentlich mit einem Timeout,
# obwohl der Dienst grundsaetzlich erreichbar ist - ein einmaliger
# Retry mit kurzer Wartezeit faengt genau diesen Fall ab, ohne bei
# einem echten Ausfall spuerbar laenger zu haengen.
letzter_fehler: httpx.HTTPError | None = None
for versuch in range(2):
if versuch:
time.sleep(2)
try:
resp = httpx.post(
OVERPASS_URL,
data={"data": query},
headers={"User-Agent": USER_AGENT},
timeout=30.0,
)
resp.raise_for_status()
return resp.json().get("elements", [])
except httpx.HTTPError as e:
letzter_fehler = e
raise UmkreissucheFehler("Umkreissuche-Dienst nicht erreichbar") from letzter_fehler
def _adresse_aus_tags(tags: dict) -> tuple[str | None, str | None, str | None]:
strasse = tags.get("addr:street")
hausnr = tags.get("addr:housenumber")
if strasse and hausnr:
strasse = f"{strasse} {hausnr}"
return strasse, tags.get("addr:postcode"), tags.get("addr:city")
def suche(plz: str, kategorie: str, radius_km: int) -> list[dict]:
kat = KATEGORIEN[kategorie]
lat, lon = _geocode_plz(plz)
elemente = _overpass_suche(kat["filters"], lat, lon, radius_km * 1000)
treffer = []
gesehene_ids = set()
for el in elemente:
el_id = (el.get("type"), el.get("id"))
if el_id in gesehene_ids:
continue
gesehene_ids.add(el_id)
tags = el.get("tags", {})
name = tags.get("name")
if not name:
continue
if "lat" in el and "lon" in el:
el_lat, el_lon = el["lat"], el["lon"]
elif "center" in el:
el_lat, el_lon = el["center"]["lat"], el["center"]["lon"]
else:
continue
strasse, plz_ort, ort = _adresse_aus_tags(tags)
treffer.append(
{
"osm_type": el.get("type"),
"osm_id": el.get("id"),
"name": name,
"kategorie": kategorie,
"strasse": strasse,
"plz": plz_ort,
"ort": ort,
"telefon": tags.get("contact:phone") or tags.get("phone"),
"email": tags.get("contact:email") or tags.get("email"),
"webseite": tags.get("contact:website") or tags.get("website"),
"entfernung_km": round(_haversine_km(lat, lon, el_lat, el_lon), 1),
}
)
treffer.sort(key=lambda t: t["entfernung_km"])
return treffer[:30]
+48
View File
@@ -0,0 +1,48 @@
"""Format-Validierung der Krankenversicherungsnummer (Tester-Feedback: "korrekte
Anzahl der Krankenversicherungs-Nummer Format und Laengen beachten - wenn falsch:
Fehler!"). Das Format unterscheidet sich je Land, hergeleitet aus der beim
Patienten hinterlegten Krankenkasse (Default DE, falls keine/unbekannte Kasse
angegeben ist). Fuer PKV wird nicht geprueft, da private Versicherer kein
einheitliches Nummernformat verwenden."""
import re
from app.static_data.krankenkassen import get_by_name
_MUSTER = {
"DE": re.compile(r"^[A-Za-z]\d{9}$"),
"AT": re.compile(r"^\d{10}$"),
"CH": re.compile(r"^756\d{10}$"),
}
_HINWEIS = {
"DE": "1 Buchstabe gefolgt von 9 Ziffern, z. B. A123456789",
"AT": "10 Ziffern (Sozialversicherungsnummer)",
"CH": "756 gefolgt von 10 Ziffern (AHV-Nummer, Trennzeichen egal)",
}
def _land_fuer_krankenkasse(krankenkasse: str | None) -> str:
if not krankenkasse:
return "DE"
kk = get_by_name(krankenkasse)
return kk.get("land", "DE") if kk else "DE"
def pruefe_versichertennummer(
nummer: str | None, krankenkasse: str | None, versicherungsart: str | None
) -> None:
"""Wirft ValueError mit einer nutzerlesbaren Meldung bei falschem Format.
Kein Fehler bei leerer Nummer (Feld ist optional) oder bei PKV."""
if not nummer or not nummer.strip():
return
if versicherungsart == "PKV":
return
bereinigt = nummer.strip().replace(" ", "").replace(".", "").replace("-", "")
land = _land_fuer_krankenkasse(krankenkasse)
muster = _MUSTER.get(land, _MUSTER["DE"])
if not muster.match(bereinigt):
raise ValueError(
f"Ungültiges Format der Versichertennummer ({land}). Erwartet: {_HINWEIS.get(land, _HINWEIS['DE'])}."
)
@@ -0,0 +1,27 @@
"""widerrufsverzicht bestaetigt am
Revision ID: 212cec88ae07
Revises: 5ff4e0a87733
Create Date: 2026-07-24 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '212cec88ae07'
down_revision = '5ff4e0a87733'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'nutzer',
sa.Column('widerrufsverzicht_bestaetigt_am', sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column('nutzer', 'widerrufsverzicht_bestaetigt_am')
@@ -0,0 +1,33 @@
"""nutzer: email + passwort-reset-token
Revision ID: 333399a0f933
Revises: f552607a045e
Create Date: 2026-07-24 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '333399a0f933'
down_revision = 'f552607a045e'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('nutzer', sa.Column('email', sa.String(length=255), nullable=True))
op.create_unique_constraint('uq_nutzer_email', 'nutzer', ['email'])
op.add_column('nutzer', sa.Column('reset_token_hash', sa.String(length=64), nullable=True))
op.add_column(
'nutzer',
sa.Column('reset_token_laeuft_ab', sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column('nutzer', 'reset_token_laeuft_ab')
op.drop_column('nutzer', 'reset_token_hash')
op.drop_constraint('uq_nutzer_email', 'nutzer', type_='unique')
op.drop_column('nutzer', 'email')
@@ -0,0 +1,30 @@
"""leistungen: rolle (eigenleistung vs. extern zu beantragen)
Revision ID: a1c9e7d2b4f6
Revises: 333399a0f933
Create Date: 2026-07-27 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1c9e7d2b4f6'
down_revision = '333399a0f933'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('leistungen', sa.Column('rolle', sa.String(length=30), nullable=True))
op.create_check_constraint(
'ck_leistung_rolle',
'leistungen',
"rolle IN ('eigenleistung','extern_beantragen')",
)
def downgrade() -> None:
op.drop_constraint('ck_leistung_rolle', 'leistungen', type_='check')
op.drop_column('leistungen', 'rolle')
@@ -0,0 +1,40 @@
"""umkreissuche_favoriten-Tabelle (je Nutzer gemerkte Umkreissuche-Treffer)
Revision ID: a7c4e9f1b2d3
Revises: f1a2b3c4d5e6
Create Date: 2026-07-28 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a7c4e9f1b2d3'
down_revision = 'f1a2b3c4d5e6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'umkreissuche_favoriten',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('nutzer_id', sa.Integer(), sa.ForeignKey('nutzer.id', ondelete='CASCADE'), nullable=False),
sa.Column('osm_type', sa.String(length=10), nullable=False),
sa.Column('osm_id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('kategorie', sa.String(length=50), nullable=False),
sa.Column('strasse', sa.String(length=300), nullable=True),
sa.Column('plz', sa.String(length=10), nullable=True),
sa.Column('ort', sa.String(length=200), nullable=True),
sa.Column('telefon', sa.String(length=100), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('webseite', sa.String(length=500), nullable=True),
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint('nutzer_id', 'osm_type', 'osm_id', name='uq_umkreissuche_favorit'),
)
def downgrade() -> None:
op.drop_table('umkreissuche_favoriten')
@@ -0,0 +1,33 @@
"""antraege: 'patientenverfuegung' als weiterer typ
Revision ID: b8d3e5f7a1c9
Revises: a7c4e9f1b2d3
Create Date: 2026-07-28 00:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b8d3e5f7a1c9'
down_revision = 'a7c4e9f1b2d3'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint('ck_antrag_typ', 'antraege', type_='check')
op.create_check_constraint(
'ck_antrag_typ',
'antraege',
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung','vollmacht','patientenverfuegung')",
)
def downgrade() -> None:
op.drop_constraint('ck_antrag_typ', 'antraege', type_='check')
op.create_check_constraint(
'ck_antrag_typ',
'antraege',
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung','vollmacht')",
)
@@ -0,0 +1,33 @@
"""antraege: 'vollmacht' als weiterer typ
Revision ID: c3f8a1d5e9b2
Revises: a1c9e7d2b4f6
Create Date: 2026-07-27 00:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'c3f8a1d5e9b2'
down_revision = 'a1c9e7d2b4f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint('ck_antrag_typ', 'antraege', type_='check')
op.create_check_constraint(
'ck_antrag_typ',
'antraege',
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung','vollmacht')",
)
def downgrade() -> None:
op.drop_constraint('ck_antrag_typ', 'antraege', type_='check')
op.create_check_constraint(
'ck_antrag_typ',
'antraege',
"typ IN ('erstantrag','widerspruch','hoeherst','adressaenderung')",
)
@@ -0,0 +1,33 @@
"""leistungen: budget_jahr + leistung_ausgaben-Tabelle (Budget-Tracker)
Revision ID: d4e7f2a8c1b3
Revises: c3f8a1d5e9b2
Create Date: 2026-07-28 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd4e7f2a8c1b3'
down_revision = 'c3f8a1d5e9b2'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('leistungen', sa.Column('budget_jahr', sa.Numeric(10, 2), nullable=True))
op.create_table(
'leistung_ausgaben',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('leistung_id', sa.Integer(), sa.ForeignKey('leistungen.id', ondelete='CASCADE'), nullable=False),
sa.Column('betrag', sa.Numeric(10, 2), nullable=False),
sa.Column('datum', sa.Date(), nullable=False),
sa.Column('beschreibung', sa.String(length=300), nullable=True),
)
def downgrade() -> None:
op.drop_table('leistung_ausgaben')
op.drop_column('leistungen', 'budget_jahr')
@@ -0,0 +1,33 @@
"""katalog_snapshots-Tabelle (Aenderungserkennung Leistungskatalog)
Revision ID: e6f9a3c2b7d1
Revises: d4e7f2a8c1b3
Create Date: 2026-07-28 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e6f9a3c2b7d1'
down_revision = 'd4e7f2a8c1b3'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'katalog_snapshots',
sa.Column('katalog_id', sa.String(length=50), primary_key=True),
sa.Column('betrag', sa.Text(), nullable=True),
sa.Column('voraussetzung', sa.Text(), nullable=True),
sa.Column('rechtsgrundlage', sa.Text(), nullable=True),
sa.Column(
'aktualisiert_am', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
def downgrade() -> None:
op.drop_table('katalog_snapshots')
@@ -0,0 +1,31 @@
"""nutzer: Tagebuch-Reminder-Einstellungen
Revision ID: f1a2b3c4d5e6
Revises: e6f9a3c2b7d1
Create Date: 2026-07-28 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e6'
down_revision = 'e6f9a3c2b7d1'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'nutzer',
sa.Column('reminder_aktiv', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column('nutzer', sa.Column('reminder_uhrzeit', sa.Time(), nullable=True))
op.add_column('nutzer', sa.Column('reminder_zuletzt_gesendet', sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column('nutzer', 'reminder_zuletzt_gesendet')
op.drop_column('nutzer', 'reminder_uhrzeit')
op.drop_column('nutzer', 'reminder_aktiv')
@@ -0,0 +1,27 @@
"""patient: berechtigung bestaetigt am
Revision ID: f552607a045e
Revises: 212cec88ae07
Create Date: 2026-07-24 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f552607a045e'
down_revision = '212cec88ae07'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'patienten',
sa.Column('berechtigung_bestaetigt_am', sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column('patienten', 'berechtigung_bestaetigt_am')
+8
View File
@@ -14,3 +14,11 @@ python-multipart>=0.0.12
jinja2>=3.1,<4
weasyprint>=63,<64
itsdangerous>=2.2,<3
httpx>=0.27,<1
# -headless statt normal: keine GUI-Abhaengigkeiten (libGL etc.) noetig, die
# der Container ohnehin nie nutzt - fuer die Dokumenten-Scan-Kantenerkennung
# (app/dokument_scan.py)
opencv-python-headless>=4.10,<5
# Python-Wrapper fuer die tesseract-ocr-Binary (Dockerfile) - eGK-Scan
# (app/egk_ocr.py)
pytesseract>=0.3,<1
+16
View File
@@ -21,6 +21,13 @@ services:
DATABASE_URL: ${DATABASE_URL}
SECRET_KEY: ${SECRET_KEY}
WEB_ORIGIN: ${WEB_ORIGIN}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-MeinPflegeFuchs <no-reply@meinpflegefuchs.de>}
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
ports:
- "${API_PORT:-8100}:5050"
volumes:
@@ -30,6 +37,7 @@ services:
- ./backend/app:/app/app
- ./backend/migrations:/app/migrations
- pflegepilot_v3_uploads:/app/app/uploads
- pflegepilot_v3_backups:/app/app/backups
depends_on:
db:
condition: service_healthy
@@ -38,6 +46,13 @@ services:
build: ./frontend
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meinpflegefuchs.de}
# Fuer serverseitige (Next.js-Server-Prozess, nicht Browser) Aufrufe an
# die API, z.B. generateMetadata()/Server Components der Landingpage -
# der interne Docker-Compose-Servicename+Port, NICHT der oeffentliche/
# host-gemappte NEXT_PUBLIC_API_URL-Wert (der ist fuer den Browser
# gedacht und auf der NAS bewusst leer, siehe (marketing)/page.tsx).
API_INTERNAL_URL: ${API_INTERNAL_URL:-http://api:5050}
NEXT_DEV_ALLOWED_ORIGINS: ${NEXT_DEV_ALLOWED_ORIGINS:-}
ports:
- "${WEB_PORT:-3100}:3000"
@@ -54,3 +69,4 @@ services:
volumes:
pflegepilot_v3_db:
pflegepilot_v3_uploads:
pflegepilot_v3_backups:
@@ -3,7 +3,10 @@
import { useEffect, useState } from "react";
import { useEinstellungen, useEinstellungenSpeichern } from "@/lib/admin";
const FELD_GRUPPEN: { titel: string; felder: { key: string; label: string; typ?: "textarea" | "checkbox" }[] }[] = [
const FELD_GRUPPEN: {
titel: string;
felder: { key: string; label: string; typ?: "textarea" | "checkbox"; hinweis?: string }[];
}[] = [
{
titel: "Betrieb",
felder: [
@@ -14,6 +17,24 @@ const FELD_GRUPPEN: { titel: string; felder: { key: string; label: string; typ?:
{ key: "firmen_tel", label: "Telefon" },
],
},
{
titel: "Landingpage & SEO",
felder: [
{ key: "landing_hero_titel", label: "Hero-Überschrift (Startseite)" },
{ key: "landing_hero_text", label: "Hero-Text (Startseite)", typ: "textarea" },
{
key: "landing_seo_title",
label: "SEO: Seitentitel",
hinweis: "Erscheint als Browser-Tab-Titel und blauer Link in Suchergebnissen, ca. 50–60 Zeichen empfohlen",
},
{
key: "landing_seo_description",
label: "SEO: Meta-Beschreibung",
typ: "textarea",
hinweis: "Erscheint als Vorschautext in Suchergebnissen, ca. 150–160 Zeichen empfohlen",
},
],
},
{
titel: "Rechtstexte",
felder: [
@@ -26,8 +47,8 @@ const FELD_GRUPPEN: { titel: string; felder: { key: string; label: string; typ?:
titel: "Abo & Zahlung",
felder: [
{ key: "abo_pflicht", label: "Zahlungspflicht aktiv (1/0)" },
{ key: "abo_preis_basis", label: "Basispreis (1 Patient)" },
{ key: "abo_preis_zusatzpatient", label: "Aufpreis je zusätzlichem Patienten" },
{ key: "abo_preis_basis", label: "Basispreis (1 Patient)", hinweis: "Bitte mit Punkt, nicht Komma eingeben (z. B. 14.99)" },
{ key: "abo_preis_zusatzpatient", label: "Aufpreis je zusätzlichem Patienten", hinweis: "Bitte mit Punkt, nicht Komma eingeben (z. B. 4.99)" },
{ key: "abo_waehrung", label: "Währung" },
],
},
@@ -84,6 +105,7 @@ export default function BackendEinstellungenPage() {
onChange={(e) => setForm((s) => ({ ...s, [f.key]: e.target.value }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
{f.hinweis && <p className="mt-1 text-xs text-slate-400">{f.hinweis}</p>}
</div>
)
)}
@@ -6,6 +6,9 @@ import {
useNutzerListe,
useAuditLog,
useAdminMutationen,
useBackups,
useBackupMutationen,
backupUrl,
dsgvoExportHerunterladen,
rechnungHerunterladen,
type NutzerAdmin,
@@ -22,18 +25,26 @@ export default function AdminPage() {
const { data: nutzer, isLoading } = useNutzerListe();
const { data: auditLog } = useAuditLog();
const { sperren, entsperren, loeschen, aboFreischalten, anlegen } = useAdminMutationen();
const { data: backups } = useBackups();
const { erstellen: backupErstellen, loeschen: backupLoeschen, wiederherstellen } = useBackupMutationen();
const [aktion, setAktion] = useState<Aktion | null>(null);
const [backupModalOffen, setBackupModalOffen] = useState(false);
const [restoreDatei, setRestoreDatei] = useState<File | null>(null);
const [restoreGrund, setRestoreGrund] = useState("");
const [restoreErfolg, setRestoreErfolg] = useState<string | null>(null);
const [monate, setMonate] = useState(1);
const [neuBenutzername, setNeuBenutzername] = useState("");
const [neuPasswort, setNeuPasswort] = useState("");
const [neuName, setNeuName] = useState("");
const [neuEmail, setNeuEmail] = useState("");
const [dsgvoPid, setDsgvoPid] = useState<number | null>(null);
const [dsgvoGrund, setDsgvoGrund] = useState("");
const [rechnungFehler, setRechnungFehler] = useState<string | null>(null);
const eigeneId = meQuery.data?.id;
const auditAccent = getAccent("indigo");
const backupAccent = getAccent("teal");
const rechnungErstellen = async (n: NutzerAdmin) => {
setRechnungFehler(null);
@@ -41,8 +52,8 @@ export default function AdminPage() {
const betrag = Number(n.abo_preis_aktuell);
const beschreibung =
n.patienten_anzahl > 1
? `Pflegepilot – Nutzung der Anwendung (${n.patienten_anzahl} Patienten)`
: "Pflegepilot – Nutzung der Anwendung";
? `MeinPflegeFuchs – Nutzung der Anwendung (${n.patienten_anzahl} Patienten)`
: "MeinPflegeFuchs – Nutzung der Anwendung";
await rechnungHerunterladen(n.id, betrag, beschreibung);
} catch (e) {
setRechnungFehler(e instanceof Error ? e.message : "Fehler beim Erstellen der Rechnung");
@@ -159,15 +170,23 @@ export default function AdminPage() {
onChange={(e) => setNeuName(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<input
placeholder="E-Mail (optional)"
type="email"
value={neuEmail}
onChange={(e) => setNeuEmail(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<button
onClick={() =>
anlegen.mutate(
{ benutzername: neuBenutzername, passwort: neuPasswort, name: neuName },
{ benutzername: neuBenutzername, passwort: neuPasswort, name: neuName, email: neuEmail },
{
onSuccess: () => {
setNeuBenutzername("");
setNeuPasswort("");
setNeuName("");
setNeuEmail("");
},
}
)
@@ -243,6 +262,138 @@ export default function AdminPage() {
</ul>
</Collapsible>
<Collapsible
title="System-Backup"
count={backups?.length ?? 0}
cardClassName={backupAccent.card}
headingClassName={backupAccent.heading}
defaultOpen={false}
>
<p className="mb-3 text-xs text-slate-500">
1:1-Sicherung aus Datenbank und allen hochgeladenen Dateien (Scans, PDFs, Fotos). Läuft
zusätzlich automatisch einmal täglich im Hintergrund; es bleiben jeweils die letzten 5
Backups erhalten.
</p>
<button
onClick={() => setBackupModalOffen(true)}
disabled={backupErstellen.isPending}
className="mb-3 rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{backupErstellen.isPending ? "Wird erstellt…" : "Backup jetzt erstellen"}
</button>
{backupErstellen.isError && (
<p className="mb-2 text-sm text-red-600">{(backupErstellen.error as Error).message}</p>
)}
<ul className="divide-y divide-slate-100">
{backups?.map((b) => (
<li key={b.dateiname} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-medium text-slate-700">
{new Date(b.erstellt_am).toLocaleString("de-DE")}
</span>
<span className="ml-2 text-xs text-slate-500">
{(b.groesse_bytes / 1024 / 1024).toFixed(1)} MB
</span>
</div>
<div className="flex items-center gap-3">
<a
href={backupUrl(b.dateiname)}
target="_blank"
rel="noreferrer"
className="text-xs text-slate-600 underline"
>
Herunterladen
</a>
<button
onClick={() => backupLoeschen.mutate(b.dateiname)}
className="text-xs text-red-600 underline"
>
Löschen
</button>
</div>
</li>
))}
{(!backups || backups.length === 0) && (
<p className="py-2 text-sm text-slate-500">Noch keine Backups vorhanden.</p>
)}
</ul>
</Collapsible>
<Collapsible
title="Backup wiederherstellen"
cardClassName={getAccent("red").card}
headingClassName={getAccent("red").heading}
defaultOpen={false}
>
<p className="mb-3 text-sm font-semibold text-red-700">
⚠️ Überschreibt unwiderruflich alle aktuellen Daten (Datenbank und Dateien) mit dem Inhalt
der hochgeladenen Datei. Vor dem Einspielen wird automatisch eine Sicherheitskopie des
jetzigen Zustands angelegt – aber prüfe unbedingt, dass du die richtige Datei auswählst.
</p>
<div className="mb-3">
<label className="mb-1 block text-xs font-medium text-slate-500">
Backup-Datei (.tar.gz)
</label>
<input
type="file"
accept=".tar.gz,application/gzip"
onChange={(e) => {
setRestoreDatei(e.target.files?.[0] ?? null);
setRestoreErfolg(null);
}}
className="text-sm"
/>
</div>
<div className="mb-3">
<label className="mb-1 block text-xs font-medium text-slate-500">Begründung (Pflicht)</label>
<textarea
value={restoreGrund}
onChange={(e) => setRestoreGrund(e.target.value)}
rows={2}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<button
onClick={() => {
if (!restoreDatei || !restoreGrund.trim()) return;
setRestoreErfolg(null);
wiederherstellen.mutate(
{ archiv: restoreDatei, begruendung: restoreGrund },
{
onSuccess: (res) => {
setRestoreErfolg(res.sicherheitskopie_vorher);
setRestoreDatei(null);
setRestoreGrund("");
},
}
);
}}
disabled={!restoreDatei || !restoreGrund.trim() || wiederherstellen.isPending}
className="rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{wiederherstellen.isPending ? "Wird eingespielt…" : "Jetzt überschreiben und wiederherstellen"}
</button>
{wiederherstellen.isError && (
<p className="mt-2 text-sm text-red-600">{(wiederherstellen.error as Error).message}</p>
)}
{restoreErfolg && (
<p className="mt-2 text-sm text-emerald-700">
Wiederhergestellt. Sicherheitskopie des vorherigen Zustands: {restoreErfolg}
</p>
)}
</Collapsible>
{backupModalOffen && (
<BegruendungModal
titel="Backup erstellen"
onConfirm={(begruendung) =>
backupErstellen.mutate(begruendung, { onSuccess: () => setBackupModalOffen(false) })
}
onClose={() => setBackupModalOffen(false)}
pending={backupErstellen.isPending}
/>
)}
{aktion && (
<BegruendungModal
titel={
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useAufgaben, useAufgabenMutationen } from "@/lib/aufgaben";
import { IconBrandApple, IconBrandGoogle, IconPlus } from "@tabler/icons-react";
import { googleCalendarUrl, icsUrl, useAufgaben, useAufgabenMutationen } from "@/lib/aufgaben";
import { ResponsiveTable, type Column } from "@/components/ResponsiveTable";
import type { Aufgabe } from "@/lib/aufgaben";
import { AufgabeModal } from "@/components/AufgabeModal";
@@ -33,6 +33,35 @@ export default function AufgabenPage() {
},
{ key: "patient", label: "Patient", render: (a) => a.patient_name ?? "–" },
{ key: "faellig", label: "Fällig am", render: (a) => a.faellig_am ?? "–" },
{
key: "kalender",
label: "Zum Kalender hinzufügen",
render: (a) =>
a.faellig_am ? (
<div className="flex items-center gap-2">
<a
href={icsUrl(a)}
title="Zu Apple Kalender hinzufügen"
aria-label="Zu Apple Kalender hinzufügen"
className="flex h-7 w-7 items-center justify-center rounded-full border border-slate-200 text-slate-600 hover:bg-slate-100"
>
<IconBrandApple size={16} />
</a>
<a
href={googleCalendarUrl(a)}
target="_blank"
rel="noreferrer"
title="Zu Google Kalender hinzufügen"
aria-label="Zu Google Kalender hinzufügen"
className="flex h-7 w-7 items-center justify-center rounded-full border border-slate-200 text-slate-600 hover:bg-slate-100"
>
<IconBrandGoogle size={16} />
</a>
</div>
) : (
"–"
),
},
{
key: "prioritaet",
label: "Priorität",
@@ -1,11 +1,139 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useDashboard } from "@/lib/patienten";
import { IconChevronDown } from "@tabler/icons-react";
import { useDashboard, type DashboardPatientKachel } from "@/lib/patienten";
import { useAntraegeFuerPatient, pdfUrl } from "@/lib/briefe";
import { useAufgaben, type Aufgabe } from "@/lib/aufgaben";
import { BentoGrid, BentoTile } from "@/components/BentoGrid";
import { getAccent } from "@/lib/theme";
const ANTRAG_TYP_LABEL: Record<string, string> = {
erstantrag: "Erstantrag",
widerspruch: "Widerspruch",
hoeherst: "Höherstufung",
adressaenderung: "Adressänderung",
vollmacht: "Vollmacht",
patientenverfuegung: "Patientenverfügung",
};
const ANTRAG_STATUS_LABEL: Record<string, string> = {
offen: "offen",
in_pruefung: "in Prüfung",
};
const OFFENE_ANTRAG_STATUS = ["offen", "in_pruefung"];
function PatientKachel({ patient, aufgaben }: { patient: DashboardPatientKachel; aufgaben: Aufgabe[] }) {
const [offen, setOffen] = useState(false);
const { data: alleAntraege, isLoading: antraegeLaden } = useAntraegeFuerPatient(offen ? patient.id : 0);
const offeneAntraege = (alleAntraege ?? []).filter((a) => OFFENE_ANTRAG_STATUS.includes(a.status));
return (
<div className="rounded-xl border border-slate-200 bg-white p-3 transition hover:border-slate-300 hover:shadow-sm">
<button
type="button"
onClick={() => setOffen((v) => !v)}
className="flex w-full items-start justify-between gap-2 text-left"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<Link
href={`/patienten/${patient.id}`}
onClick={(e) => e.stopPropagation()}
className="truncate font-medium text-slate-800 hover:underline"
>
{patient.vorname} {patient.nachname}
</Link>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${getAccent("indigo").badge}`}>
PG {patient.pflegegrad}
</span>
</div>
<p className="mt-0.5 text-xs text-slate-500 capitalize">{patient.patient_status}</p>
{(patient.offene_antraege > 0 || patient.offene_aufgaben > 0) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{patient.offene_antraege > 0 && (
<span className={`rounded-full px-2 py-0.5 text-xs ${getAccent("violet").badge}`}>
{patient.offene_antraege} Antrag{patient.offene_antraege > 1 ? "äge" : ""} offen
</span>
)}
{patient.offene_aufgaben > 0 && (
<span className={`rounded-full px-2 py-0.5 text-xs ${getAccent("orange").badge}`}>
{patient.offene_aufgaben} Aufgabe{patient.offene_aufgaben > 1 ? "n" : ""} offen
</span>
)}
</div>
)}
</div>
{(patient.offene_antraege > 0 || patient.offene_aufgaben > 0) && (
<IconChevronDown
size={16}
className={`mt-1 shrink-0 text-slate-400 transition-transform ${offen ? "rotate-180" : ""}`}
/>
)}
</button>
{offen && (patient.offene_antraege > 0 || patient.offene_aufgaben > 0) && (
<div className="mt-3 border-t border-slate-100 pt-3">
{patient.offene_antraege > 0 && (
<div className="mb-2">
<p className="mb-1 text-xs font-semibold text-slate-500">Offene Anträge</p>
{antraegeLaden ? (
<p className="text-xs text-slate-400">Lädt…</p>
) : (
<ul className="flex flex-col gap-1">
{offeneAntraege.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2 text-xs">
<span className="text-slate-600">
{ANTRAG_TYP_LABEL[a.typ] ?? a.typ}
<span className="ml-1 text-slate-400">
· {ANTRAG_STATUS_LABEL[a.status] ?? a.status}
</span>
</span>
{a.pdf_pfad && (
<a
href={pdfUrl(patient.id, a.id)}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-slate-500 underline"
>
PDF
</a>
)}
</li>
))}
</ul>
)}
</div>
)}
{patient.offene_aufgaben > 0 && (
<div>
<p className="mb-1 text-xs font-semibold text-slate-500">Offene Aufgaben</p>
<ul className="flex flex-col gap-1">
{aufgaben.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2 text-xs">
<span className="text-slate-600">{a.titel}</span>
{a.faellig_am && (
<span className="shrink-0 text-slate-400">
{new Date(a.faellig_am).toLocaleDateString("de-DE")}
</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
export default function DashboardPage() {
const { data, isLoading } = useDashboard();
const { data: aufgaben } = useAufgaben();
return (
<div className="mx-auto max-w-5xl p-6">
@@ -19,10 +147,12 @@ export default function DashboardPage() {
<p className="text-xs font-medium text-slate-500">Patienten</p>
<p className="mt-1 text-3xl font-semibold text-slate-800">{data.patienten_anzahl}</p>
</BentoTile>
<BentoTile accent="lavender">
<p className="text-xs font-medium text-slate-500">Offene Anträge</p>
<p className="mt-1 text-3xl font-semibold text-slate-800">{data.offene_antraege}</p>
</BentoTile>
<a href="#offene-antraege" className="block">
<BentoTile accent="lavender" className="cursor-pointer transition hover:shadow-md">
<p className="text-xs font-medium text-slate-500">Offene Anträge</p>
<p className="mt-1 text-3xl font-semibold text-slate-800">{data.offene_antraege}</p>
</BentoTile>
</a>
<BentoTile accent="peach">
<p className="text-xs font-medium text-slate-500">Offene Aufgaben</p>
<p className="mt-1 text-3xl font-semibold text-slate-800">{data.offene_aufgaben}</p>
@@ -35,22 +165,54 @@ export default function DashboardPage() {
</BentoTile>
<BentoTile span={4}>
<p className="mb-3 text-sm font-semibold text-slate-700">Zuletzt bearbeitete Patienten</p>
{data.letzte_patienten.length === 0 ? (
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-700">Patienten im Überblick</p>
<Link href="/patienten" className="text-xs text-slate-500 underline">
Alle ansehen
</Link>
</div>
{data.patienten_uebersicht.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Patienten angelegt.</p>
) : (
<div className="grid grid-cols-1 items-start gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.patienten_uebersicht.map((p) => (
<PatientKachel
key={p.id}
patient={p}
aufgaben={(aufgaben ?? []).filter((a) => a.patient_id === p.id)}
/>
))}
</div>
)}
</BentoTile>
{data.offene_antraege_nach_patient.length > 0 && (
<BentoTile span={4} id="offene-antraege">
<p className="mb-3 text-sm font-semibold text-slate-700">Offene Anträge nach Patient</p>
<ul className="divide-y divide-slate-100">
{data.letzte_patienten.map((p) => (
<li key={p.id} className="py-2">
<Link href={`/patienten/${p.id}`} className="text-sm text-slate-700 hover:underline">
{p.vorname} {p.nachname}
<span className="ml-2 text-xs text-slate-500">Pflegegrad {p.pflegegrad}</span>
{data.offene_antraege_nach_patient.map((e) => (
<li key={e.patient_id}>
<Link
href={`/patienten/${e.patient_id}`}
className="flex items-center justify-between gap-2 py-2 text-sm hover:text-slate-900"
>
<span className="text-slate-700">
{e.patient_name}
<span className="ml-2 text-xs text-slate-500">
{ANTRAG_TYP_LABEL[e.naechster_typ] ?? e.naechster_typ} ·{" "}
{ANTRAG_STATUS_LABEL[e.naechster_status] ?? e.naechster_status}
{e.anzahl > 1 && ` · +${e.anzahl - 1} weitere`}
</span>
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${getAccent("violet").badge}`}>
{e.anzahl}
</span>
</Link>
</li>
))}
</ul>
)}
</BentoTile>
</BentoTile>
)}
</BentoGrid>
)}
</div>
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/lib/auth";
export default function EinstellungenPage() {
const { meQuery, reminderEinstellungenSetzen } = useAuth();
const me = meQuery.data;
const [aktiv, setAktiv] = useState(false);
const [uhrzeit, setUhrzeit] = useState("18:00");
const [geladen, setGeladen] = useState(false);
useEffect(() => {
if (me && !geladen) {
setAktiv(me.reminder_aktiv);
setUhrzeit(me.reminder_uhrzeit ? me.reminder_uhrzeit.slice(0, 5) : "18:00");
setGeladen(true);
}
}, [me, geladen]);
if (meQuery.isLoading || !me) {
return <div className="mx-auto max-w-md p-6 text-slate-500">Lädt…</div>;
}
const speichern = () => {
reminderEinstellungenSetzen.mutate({
reminder_aktiv: aktiv,
reminder_uhrzeit: aktiv ? uhrzeit : null,
});
};
return (
<div className="mx-auto max-w-md p-6">
<h1 className="mb-4 text-2xl font-semibold text-slate-800">Einstellungen</h1>
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<p className="mb-1 text-sm font-semibold text-slate-700">Tagebuch-Erinnerung</p>
<p className="mb-3 text-xs text-slate-500">
Eine kurze tägliche E-Mail-Erinnerung, damit das Pflegetagebuch nicht in Vergessenheit
gerät: „Wie war der Tag mit {'{'}Name{'}'} heute?"
</p>
<label className="mb-3 flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={aktiv} onChange={(e) => setAktiv(e.target.checked)} />
Tägliche Erinnerung per E-Mail aktivieren
</label>
{aktiv && (
<div className="mb-3">
<label className="mb-1 block text-xs font-medium text-slate-500">Uhrzeit</label>
<input
type="time"
value={uhrzeit}
onChange={(e) => setUhrzeit(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
)}
{!me.email && aktiv && (
<p className="mb-3 text-xs text-amber-600">
Für Ihr Konto ist keine E-Mail-Adresse hinterlegt – die Erinnerung kann ohne E-Mail-Adresse
nicht zugestellt werden.
</p>
)}
<button
onClick={speichern}
disabled={reminderEinstellungenSetzen.isPending}
className="w-full rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Speichern
</button>
{reminderEinstellungenSetzen.isSuccess && (
<p className="mt-2 text-sm text-emerald-700">Gespeichert.</p>
)}
{reminderEinstellungenSetzen.isError && (
<p className="mt-2 text-sm text-red-600">
{(reminderEinstellungenSetzen.error as Error).message}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useKrisenGuides } from "@/lib/guides";
import { Collapsible } from "@/components/Collapsible";
import { getAccent } from "@/lib/theme";
const AKZENTE = ["rose", "orange", "indigo", "teal"] as const;
export default function GuidesPage() {
const { data: guides, isLoading } = useKrisenGuides();
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="mb-1 text-2xl font-semibold text-slate-800">Krisen-Checklisten</h1>
<p className="mb-4 text-sm text-slate-500">
Schnell abrufbare Schritt-für-Schritt-Anleitungen für den Ernstfall. Ersetzt im Notfall nie
den Anruf bei 112 oder die Rücksprache mit dem behandelnden Arzt.
</p>
{isLoading && <p className="text-slate-500">Lädt…</p>}
<div className="flex flex-col gap-4">
{guides?.map((guide, i) => {
const accent = getAccent(AKZENTE[i % AKZENTE.length]);
return (
<Collapsible
key={guide.id}
title={guide.titel}
defaultOpen={false}
cardClassName={accent.card}
headingClassName={accent.heading}
>
<p className="mb-3 text-sm text-slate-600">{guide.kurz}</p>
<ol className="flex flex-col gap-2">
{guide.schritte.map((schritt, idx) => (
<li key={idx} className="flex gap-2 text-sm text-slate-700">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${accent.badge}`}
>
{idx + 1}
</span>
<span>{schritt}</span>
</li>
))}
</ol>
</Collapsible>
);
})}
</div>
</div>
);
}
@@ -12,6 +12,19 @@ const ZIELGRUPPEN = [
{ value: "behinderte", label: "Behinderung" },
];
const LANDESLEISTUNG_PRAEFIX = "Landesleistungen (";
function LeistungsKarte({ item }: { item: KatalogEintrag }) {
return (
<div className="rounded-xl border border-white/70 bg-white/85 p-4">
<p className="font-medium text-slate-800">{item.name}</p>
<p className="mt-1 text-xs text-slate-600">{item.kurz}</p>
<p className="mt-2 text-xs text-slate-500">{item.rechtsgrundlage}</p>
<p className="mt-1 text-sm font-medium text-slate-700">{item.betrag}</p>
</div>
);
}
export default function KatalogPage() {
const [q, setQ] = useState("");
const [zielgruppe, setZielgruppe] = useState("");
@@ -19,13 +32,22 @@ export default function KatalogPage() {
const { data, isLoading } = useKatalog({ q: q || undefined, zielgruppe: zielgruppe || undefined, pflegegrad });
const gruppiert = new Map<string, KatalogEintrag[]>();
const bundeslaender = new Map<string, KatalogEintrag[]>();
if (data) {
for (const item of data.items) {
const liste = gruppiert.get(item.kategorie) ?? [];
liste.push(item);
gruppiert.set(item.kategorie, liste);
if (item.kategorie.startsWith(LANDESLEISTUNG_PRAEFIX)) {
const land = item.kategorie.slice(LANDESLEISTUNG_PRAEFIX.length, -1);
const liste = bundeslaender.get(land) ?? [];
liste.push(item);
bundeslaender.set(land, liste);
} else {
const liste = gruppiert.get(item.kategorie) ?? [];
liste.push(item);
gruppiert.set(item.kategorie, liste);
}
}
}
const bundeslaenderGesamt = [...bundeslaender.values()].reduce((sum, l) => sum + l.length, 0);
return (
<div className="mx-auto max-w-5xl p-6">
@@ -82,20 +104,32 @@ export default function KatalogPage() {
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<div
key={item.id}
className="rounded-xl border border-white/70 bg-white/85 p-4"
>
<p className="font-medium text-slate-800">{item.name}</p>
<p className="mt-1 text-xs text-slate-600">{item.kurz}</p>
<p className="mt-2 text-xs text-slate-500">{item.rechtsgrundlage}</p>
<p className="mt-1 text-sm font-medium text-slate-700">{item.betrag}</p>
</div>
<LeistungsKarte key={item.id} item={item} />
))}
</div>
</Collapsible>
);
})}
{bundeslaender.size > 0 && (
<Collapsible
title="Bundeslandspezifische Regelungen"
count={bundeslaenderGesamt}
defaultOpen={false}
>
<div className="flex flex-col gap-3">
{[...bundeslaender.entries()].map(([land, items]) => (
<Collapsible key={land} title={land} count={items.length} defaultOpen={false}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<LeistungsKarte key={item.id} item={item} />
))}
</div>
</Collapsible>
))}
</div>
</Collapsible>
)}
</div>
</>
)}
@@ -11,8 +11,12 @@ import {
IconScale,
IconMail,
IconTruck,
IconSignature,
IconChecklist,
IconCalendarEvent,
IconFirstAidKit,
IconMapPin,
IconClipboardHeart,
IconCreditCard,
IconSparkles,
IconSettings,
@@ -44,6 +48,8 @@ const NUTZER_SECTIONS: NavSection[] = [
{ href: "/widerspruch", label: "Widerspruch", icon: IconScale },
{ href: "/musterbrief", label: "Musterbrief", icon: IconMail },
{ href: "/ummeldung", label: "Ummeldung (Umzug)", icon: IconTruck },
{ href: "/vollmacht", label: "Vollmacht", icon: IconSignature },
{ href: "/patientenverfuegung", label: "Patientenverfügung", icon: IconClipboardHeart },
],
},
{
@@ -51,12 +57,15 @@ const NUTZER_SECTIONS: NavSection[] = [
items: [
{ href: "/aufgaben", label: "Aufgaben", icon: IconChecklist },
{ href: "/kalender", label: "Kalender", icon: IconCalendarEvent },
{ href: "/guides", label: "Krisen-Checklisten", icon: IconFirstAidKit },
{ href: "/umkreissuche", label: "Umkreissuche", icon: IconMapPin },
],
},
{
title: "Konto",
items: [
{ href: "/meinabo", label: "Mein Abo", icon: IconCreditCard },
{ href: "/einstellungen", label: "Einstellungen", icon: IconSettings },
{ href: "/changelog", label: "Was ist neu?", icon: IconSparkles },
],
},
@@ -74,7 +83,7 @@ const ADMIN_SECTIONS: NavSection[] = [
const NUTZER_NUR_PFADE = [
"/dashboard", "/patienten", "/katalog", "/rechner", "/widerspruch", "/musterbrief", "/ummeldung",
"/meinabo", "/aufgaben", "/kalender",
"/vollmacht", "/patientenverfuegung", "/meinabo", "/aufgaben", "/kalender", "/guides", "/einstellungen", "/umkreissuche",
];
export default function AppLayout({ children }: { children: React.ReactNode }) {
@@ -123,7 +132,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
}`}
>
<div className="flex items-center justify-between px-5 py-4">
<span className="text-lg font-semibold text-slate-800">Pflegepilot</span>
<span className="text-lg font-semibold text-slate-800">MeinPflegeFuchs</span>
<button
onClick={() => setMobileOpen(false)}
className="text-slate-500 lg:hidden"
@@ -178,6 +187,18 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
Abmelden
</button>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 border-t border-slate-200 px-4 py-3 text-xs text-slate-400">
<Link href="/impressum" target="_blank" rel="noreferrer" className="hover:text-slate-600">
Impressum
</Link>
<Link href="/datenschutz" target="_blank" rel="noreferrer" className="hover:text-slate-600">
Datenschutz
</Link>
<Link href="/agb" target="_blank" rel="noreferrer" className="hover:text-slate-600">
AGB
</Link>
</div>
</aside>
<div className="flex min-h-screen flex-1 flex-col">
@@ -189,7 +210,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
>
<IconMenu2 size={24} />
</button>
<span className="font-semibold text-slate-800">Pflegepilot</span>
<span className="font-semibold text-slate-800">MeinPflegeFuchs</span>
</header>
<main className="flex-1">{children}</main>
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { usePatienten, type Patient } from "@/lib/patienten";
import { useAntragMutationen, pdfUrl } from "@/lib/briefe";
import { useAuth } from "@/lib/auth";
import { AntragCheckliste } from "@/components/AntragCheckliste";
import { Collapsible } from "@/components/Collapsible";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { getAccent } from "@/lib/theme";
@@ -17,12 +19,14 @@ const TYPEN = [
export default function MusterbriefPage() {
const { data: patienten } = usePatienten();
const { meQuery } = useAuth();
const [patientId, setPatientId] = useState<number | null>(null);
const [absenderName, setAbsenderName] = useState("");
const [absenderStrasse, setAbsenderStrasse] = useState("");
const [absenderPlzOrt, setAbsenderPlzOrt] = useState("");
const [absenderTelefon, setAbsenderTelefon] = useState("");
const [absenderEmail, setAbsenderEmail] = useState("");
const [versicherterName, setVersicherterName] = useState("");
const [geburtsdatum, setGeburtsdatum] = useState<string | null>(null);
const [versichertennummer, setVersichertennummer] = useState("");
@@ -44,6 +48,9 @@ export default function MusterbriefPage() {
setAbsenderStrasse(p.strasse ?? "");
setAbsenderPlzOrt([p.plz, p.ort].filter(Boolean).join(" "));
setAbsenderTelefon(p.telefon ?? "");
// Patient hat meist keine eigene E-Mail-Adresse - dann die des
// eingeloggten (bevollmächtigten) Nutzers vorschlagen, statt leer zu lassen.
setAbsenderEmail(p.email ?? meQuery.data?.email ?? "");
setVersicherterName(`${p.vorname} ${p.nachname}`);
setGeburtsdatum(p.geburtsdatum);
setVersichertennummer(p.versichertennummer ?? "");
@@ -60,6 +67,7 @@ export default function MusterbriefPage() {
absender_strasse: absenderStrasse,
absender_plz_ort: absenderPlzOrt,
absender_telefon: absenderTelefon,
absender_email: absenderEmail || undefined,
krankenkasse,
typ,
versicherter_name: versicherterName,
@@ -111,6 +119,8 @@ export default function MusterbriefPage() {
</select>
</div>
{patientId && <AntragCheckliste patientId={patientId} typ={typ} />}
<input
placeholder="Absender: Name"
value={absenderName}
@@ -135,6 +145,12 @@ export default function MusterbriefPage() {
onChange={(e) => setAbsenderTelefon(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<input
placeholder="Absender: E-Mail"
value={absenderEmail}
onChange={(e) => setAbsenderEmail(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<KrankenkasseAutocomplete
value={krankenkasse}
@@ -4,9 +4,13 @@ import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { usePatient, usePatientMutations, type Patient } from "@/lib/patienten";
import { usePflegegradFragen } from "@/lib/pflegegrad";
import { usePflegegradFragen, useKrankenkasseLand } from "@/lib/pflegegrad";
import { pruefeVersichertennummer } from "@/lib/versichertennummer";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { DateWheelPicker } from "@/components/DateWheelPicker";
import { EgkScan } from "@/components/EgkScan";
import type { EgkScanErgebnis } from "@/lib/egk";
import { usePlzSync } from "@/lib/plz";
type FormData = {
vorname: string;
@@ -63,6 +67,8 @@ export default function PatientBearbeitenPage({ params }: { params: Promise<{ id
const [form, setForm] = useState<FormData>(leer());
const [geladen, setGeladen] = useState(false);
const [versichertennummerBeruehrt, setVersichertennummerBeruehrt] = useState(false);
const [plzBeruehrt, setPlzBeruehrt] = useState(false);
useEffect(() => {
if (patient && !geladen) {
@@ -71,13 +77,33 @@ export default function PatientBearbeitenPage({ params }: { params: Promise<{ id
}
}, [patient, geladen]);
const set = <K extends keyof FormData>(field: K, value: FormData[K]) =>
setForm((s) => ({ ...s, [field]: value }));
// aktiv erst nach einer echten Nutzereingabe im PLZ-Feld (siehe
// usePlzSync-Dokumentation) - schuetzt die beim Laden bereits vorhandene
// Adresse davor, ungefragt ueberschrieben zu werden.
usePlzSync(form.plz, (v) => set("ort", v), plzBeruehrt);
const egkUebernehmen = (ergebnis: EgkScanErgebnis) => {
if (ergebnis.vorname) set("vorname", ergebnis.vorname);
if (ergebnis.nachname) set("nachname", ergebnis.nachname);
if (ergebnis.geburtsdatum) set("geburtsdatum", ergebnis.geburtsdatum);
if (ergebnis.krankenkasse) set("krankenkasse", ergebnis.krankenkasse);
if (ergebnis.versichertennummer) set("versichertennummer", ergebnis.versichertennummer);
};
const krankenkasseLand = useKrankenkasseLand(form.krankenkasse);
const versichertennummerFehler = pruefeVersichertennummer(
form.versichertennummer,
krankenkasseLand,
form.versicherungsart
);
if (isLoading || !patient) {
return <div className="mx-auto max-w-lg p-6 text-slate-500">Lädt…</div>;
}
const set = <K extends keyof FormData>(field: K, value: FormData[K]) =>
setForm((s) => ({ ...s, [field]: value }));
const speichern = () => {
aktualisieren.mutate(
{
@@ -127,7 +153,15 @@ export default function PatientBearbeitenPage({ params }: { params: Promise<{ id
<Field label="Straße und Hausnummer" value={form.strasse} onChange={(v) => set("strasse", v)} />
<div className="flex gap-2">
<div className="w-28">
<Field label="PLZ" value={form.plz} onChange={(v) => set("plz", v)} />
<Field
label="PLZ"
value={form.plz}
onChange={(v) => {
setPlzBeruehrt(true);
set("plz", v);
}}
maxLength={5}
/>
</div>
<div className="flex-1">
<Field label="Ort" value={form.ort} onChange={(v) => set("ort", v)} />
@@ -137,8 +171,28 @@ export default function PatientBearbeitenPage({ params }: { params: Promise<{ id
<Field label="E-Mail" value={form.email} onChange={(v) => set("email", v)} />
<p className="mt-2 text-xs font-medium text-slate-500">Krankenversicherung</p>
<EgkScan onErgebnis={egkUebernehmen} />
<KrankenkasseAutocomplete value={form.krankenkasse} onChange={(v) => set("krankenkasse", v)} />
<Field label="Versichertennummer" value={form.versichertennummer} onChange={(v) => set("versichertennummer", v)} />
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Versichertennummer</label>
<input
value={form.versichertennummer}
onChange={(e) => set("versichertennummer", e.target.value)}
onBlur={() => setVersichertennummerBeruehrt(true)}
className={`w-full rounded-lg border px-3 py-2 text-sm ${
versichertennummerBeruehrt && versichertennummerFehler
? "border-red-400"
: "border-slate-300"
}`}
/>
{versichertennummerBeruehrt && versichertennummerFehler ? (
<p className="mt-1 text-xs text-red-600">{versichertennummerFehler}</p>
) : (
<p className="mt-1 text-xs text-slate-400">
Format hängt von der Krankenkasse ab (DE z. B. A123456789).
</p>
)}
</div>
<Field
label="Rentenversicherungsnummer (optional)"
value={form.rentenversicherungsnummer}
@@ -178,7 +232,12 @@ export default function PatientBearbeitenPage({ params }: { params: Promise<{ id
<div className="mt-4 flex gap-2">
<button
onClick={speichern}
disabled={aktualisieren.isPending || !form.vorname.trim() || !form.nachname.trim()}
disabled={
aktualisieren.isPending ||
!form.vorname.trim() ||
!form.nachname.trim() ||
!!versichertennummerFehler
}
className="flex-1 rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Speichern
@@ -203,11 +262,13 @@ function Field({
value,
onChange,
required,
maxLength,
}: {
label: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
maxLength?: number;
}) {
return (
<div className="flex-1">
@@ -218,6 +279,7 @@ function Field({
<input
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={maxLength}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
@@ -1,19 +1,33 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { Suspense, use, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { usePatient, useNotizenVerlauf, usePatientMutations } from "@/lib/patienten";
import { useAntraegeFuerPatient, pdfUrl } from "@/lib/briefe";
import { useTagebuch, useTagebuchMutationen } from "@/lib/tagebuch";
import { useDokumente, useDokumentMutationen, dokumentUrl } from "@/lib/dokumente";
import { useDokumente, useDokumentMutationen, useDokumentTypen, dokumentUrl } from "@/lib/dokumente";
import { useLeistungen, useLeistungMutationen, type LeistungRolle } from "@/lib/leistungen";
import { useKatalog } from "@/lib/katalog";
import { pflegepauschbetragFuer } from "@/lib/pflegepauschbetrag";
import { Collapsible } from "@/components/Collapsible";
import { BudgetTracker } from "@/components/BudgetTracker";
import { DokumentScan } from "@/components/DokumentScan";
import { getAccent } from "@/lib/theme";
export default function PatientDetailPage({ params }: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<div className="mx-auto max-w-3xl p-6 text-slate-500">Lädt…</div>}>
<PatientDetailPageInner params={params} />
</Suspense>
);
}
function PatientDetailPageInner({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const pid = Number(id);
const router = useRouter();
const searchParams = useSearchParams();
const { data: patient, isLoading } = usePatient(pid);
const { data: verlauf } = useNotizenVerlauf(pid);
@@ -21,17 +35,57 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
const { data: tagebuch } = useTagebuch(pid);
const { anlegen: tagebuchAnlegen, loeschen: tagebuchLoeschen } = useTagebuchMutationen(pid);
const { data: dokumente } = useDokumente(pid);
const { hochladen, loeschen: dokumentLoeschen } = useDokumentMutationen(pid);
const { data: dokumentTypen } = useDokumentTypen();
const { hochladen, scanHochladen, loeschen: dokumentLoeschen } = useDokumentMutationen(pid);
const { data: leistungen } = useLeistungen(pid);
const {
anlegen: leistungAnlegen,
ausKatalogAnlegen,
rolleSetzen: leistungRolleSetzen,
aktivieren: leistungAktivieren,
deaktivieren: leistungDeaktivieren,
loeschen: leistungLoeschen,
} = useLeistungMutationen(pid);
const { data: katalogFuerPatient } = useKatalog({
zielgruppe: patient?.zielgruppe,
pflegegrad: patient?.pflegegrad,
});
const { pflegegradSetzen, notizenAendern, loeschen } = usePatientMutations();
const [notizText, setNotizText] = useState("");
const [grund, setGrund] = useState("");
const [tbForm, setTbForm] = useState({ datum: new Date().toISOString().slice(0, 10), allgemein: "" });
const [neuerDokumentTyp, setNeuerDokumentTyp] = useState("sonstiges");
const [neueKatalogId, setNeueKatalogId] = useState("");
const [eigeneOffen, setEigeneOffen] = useState(false);
const [eigeneLeistung, setEigeneLeistung] = useState({
typ: "",
bezeichnung: "",
betrag: "",
einheit: "monatlich",
});
const [leistungenFehlerHinweis, setLeistungenFehlerHinweis] = useState(false);
useEffect(() => {
if (searchParams.get("leistungen_fehler") === "1") {
setLeistungenFehlerHinweis(true);
router.replace(`/patienten/${pid}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dokumentTypLabel = (typ: string) =>
dokumentTypen?.find((t) => t.typ === typ)?.label ?? typ;
if (isLoading || !patient) {
return <div className="mx-auto max-w-3xl p-6 text-slate-500">Lädt…</div>;
}
const bereitsZugewiesenKatalogIds = new Set((leistungen ?? []).map((l) => l.katalog_id).filter(Boolean));
const verfuegbareKatalogEintraege = (katalogFuerPatient?.items ?? []).filter(
(k) => !bereitsZugewiesenKatalogIds.has(k.id)
);
const notizSpeichern = (e: React.FormEvent) => {
e.preventDefault();
if (!grund.trim()) return;
@@ -44,6 +98,7 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
const antraegeAccent = getAccent("violet");
const tagebuchAccent = getAccent("emerald");
const dokumenteAccent = getAccent("amber");
const leistungenAccent = getAccent("sky");
return (
<div className="mx-auto max-w-3xl p-6">
@@ -68,6 +123,14 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{leistungenFehlerHinweis && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Patient wurde angelegt, aber die im Interview ausgewählten Leistungen konnten nicht
automatisch gespeichert werden. Bitte weiter unten im Bereich „Leistungen" manuell
nachtragen.
</div>
)}
<section className="mb-6 grid grid-cols-2 gap-x-6 gap-y-2 rounded-2xl border border-slate-200 bg-white p-5 text-sm">
<Field label="Geburtsdatum" value={patient.geburtsdatum} />
<Field label="Zielgruppe" value={patient.zielgruppe} />
@@ -140,6 +203,202 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
)}
</section>
<div className="mb-6">
<Collapsible
title="Leistungen"
count={leistungen?.length ?? 0}
cardClassName={leistungenAccent.card}
headingClassName={leistungenAccent.heading}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<select
value={neueKatalogId}
onChange={(e) => setNeueKatalogId(e.target.value)}
className="min-w-[220px] rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
>
<option value="">– Leistung aus Katalog wählen –</option>
{verfuegbareKatalogEintraege.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
<button
type="button"
disabled={!neueKatalogId || ausKatalogAnlegen.isPending}
onClick={() =>
ausKatalogAnlegen.mutate([neueKatalogId], { onSuccess: () => setNeueKatalogId("") })
}
className="rounded-lg bg-slate-800 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
>
Hinzufügen
</button>
<button
type="button"
onClick={() => setEigeneOffen((v) => !v)}
className="text-xs text-slate-600 underline"
>
{eigeneOffen ? "Abbrechen" : "+ eigene Leistung erfassen"}
</button>
</div>
{eigeneOffen && (
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Typ</label>
<input
value={eigeneLeistung.typ}
onChange={(e) => setEigeneLeistung((f) => ({ ...f, typ: e.target.value }))}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Bezeichnung</label>
<input
value={eigeneLeistung.bezeichnung}
onChange={(e) => setEigeneLeistung((f) => ({ ...f, bezeichnung: e.target.value }))}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Betrag (€)</label>
<input
value={eigeneLeistung.betrag}
onChange={(e) => setEigeneLeistung((f) => ({ ...f, betrag: e.target.value }))}
className="w-24 rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Einheit</label>
<select
value={eigeneLeistung.einheit}
onChange={(e) => setEigeneLeistung((f) => ({ ...f, einheit: e.target.value }))}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
>
<option value="monatlich">monatlich</option>
<option value="jaehrlich">jährlich</option>
<option value="einmalig">einmalig</option>
</select>
</div>
<button
type="button"
disabled={
!eigeneLeistung.typ.trim() ||
!eigeneLeistung.bezeichnung.trim() ||
leistungAnlegen.isPending
}
onClick={() =>
leistungAnlegen.mutate(
{
typ: eigeneLeistung.typ,
bezeichnung: eigeneLeistung.bezeichnung,
betrag: eigeneLeistung.betrag ? Number(eigeneLeistung.betrag) : undefined,
einheit: eigeneLeistung.einheit,
},
{
onSuccess: () => {
setEigeneLeistung({ typ: "", bezeichnung: "", betrag: "", einheit: "monatlich" });
setEigeneOffen(false);
},
}
)
}
className="rounded-lg bg-slate-800 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
>
Speichern
</button>
</div>
)}
{leistungen && leistungen.some((l) => l.rolle === "eigenleistung") && (
<div className="mb-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
{patient.pflegegrad >= 2 ? (
<>
<span className="font-semibold">Steuertipp: </span>
Für die selbst erbrachte Pflege bei Pflegegrad {patient.pflegegrad} lässt sich unter
Umständen der <strong>Pflegepauschbetrag</strong> (§ 33b Abs. 6 EStG) von{" "}
<strong>{pflegepauschbetragFuer(patient.pflegegrad)} € pro Jahr</strong> als
außergewöhnliche Belastung in der Steuererklärung geltend machen – vorausgesetzt, die
Pflege erfolgt persönlich, im häuslichen Umfeld und unentgeltlich (ein weitergeleitetes
Pflegegeld schadet dabei nicht). Keine Steuerberatung – bitte im Einzelfall prüfen bzw.
mit einem Steuerberater abstimmen.
</>
) : (
<>
Der Pflegepauschbetrag (§ 33b Abs. 6 EStG) für selbst erbrachte Pflege gilt erst ab
Pflegegrad 2.
</>
)}
</div>
)}
<ul className="divide-y divide-slate-900/5">
{leistungen?.map((l) => (
<li key={l.id} className="flex flex-col gap-1.5 py-2 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={`font-medium ${l.aktiv ? "text-slate-700" : "text-slate-400 line-through"}`}>
{l.bezeichnung}
</span>
<span className="ml-2 text-xs text-slate-500">{l.typ}</span>
{l.betrag && (
<span className="ml-2 text-xs text-slate-500">
{l.betrag} € / {l.einheit}
</span>
)}
{!l.aktiv && <span className="ml-2 text-xs text-amber-600">inaktiv</span>}
</div>
<div className="flex items-center gap-3 text-xs">
<button
onClick={() =>
l.aktiv ? leistungDeaktivieren.mutate(l.id) : leistungAktivieren.mutate(l.id)
}
className="text-slate-600 underline"
>
{l.aktiv ? "Deaktivieren" : "Aktivieren"}
</button>
<button onClick={() => leistungLoeschen.mutate(l.id)} className="text-red-600 underline">
Löschen
</button>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-400">Wer übernimmt das:</span>
{(
[
{ wert: "eigenleistung", label: "Ich selbst" },
{ wert: "extern_beantragen", label: "Extern beantragen" },
] as { wert: LeistungRolle; label: string }[]
).map((o) => (
<button
key={o.wert}
type="button"
onClick={() =>
leistungRolleSetzen.mutate({
lid: l.id,
rolle: l.rolle === o.wert ? null : o.wert,
})
}
className={`rounded-full px-2.5 py-0.5 text-xs ${
l.rolle === o.wert
? "bg-slate-800 text-white"
: "border border-slate-300 text-slate-600"
}`}
>
{o.label}
</button>
))}
</div>
<BudgetTracker leistung={l} />
</li>
))}
{(!leistungen || leistungen.length === 0) && (
<p className="py-2 text-sm text-slate-500">Noch keine Leistungen erfasst.</p>
)}
</ul>
</Collapsible>
</div>
<div className="mb-6">
<Collapsible
title="Anträge & Briefe"
@@ -157,6 +416,9 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
<Link href="/ummeldung" className="text-slate-600 underline">
+ Ummeldung
</Link>
<Link href="/vollmacht" className="text-slate-600 underline">
+ Vollmacht
</Link>
</div>
{!antraege || antraege.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Anträge oder Briefe.</p>
@@ -267,23 +529,63 @@ export default function PatientDetailPage({ params }: { params: Promise<{ id: st
cardClassName={dokumenteAccent.card}
headingClassName={dokumenteAccent.heading}
>
<input
type="file"
onChange={(e) => {
const datei = e.target.files?.[0];
if (!datei) return;
hochladen.mutate({ datei, typ: "sonstiges", bezeichnung: datei.name });
e.target.value = "";
}}
className="mb-3 text-sm"
/>
{hochladen.isError && <p className="mb-2 text-sm text-red-600">{(hochladen.error as Error).message}</p>}
<div className="mb-3">
<label className="mb-1 block text-xs font-medium text-slate-500">Dokumenttyp</label>
<select
value={neuerDokumentTyp}
onChange={(e) => setNeuerDokumentTyp(e.target.value)}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-sm"
>
{(dokumentTypen ?? []).map((t) => (
<option key={t.typ} value={t.typ}>
{t.label}
</option>
))}
</select>
</div>
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-slate-200 p-2.5">
<p className="mb-1.5 text-xs font-medium text-slate-500">Datei hochladen</p>
<input
type="file"
onChange={(e) => {
const datei = e.target.files?.[0];
if (!datei) return;
hochladen.mutate({ datei, typ: neuerDokumentTyp, bezeichnung: datei.name });
e.target.value = "";
}}
className="text-sm"
/>
{hochladen.isError && (
<p className="mt-1.5 text-xs text-red-600">{(hochladen.error as Error).message}</p>
)}
</div>
<div className="rounded-lg border border-slate-200 p-2.5">
<p className="mb-1.5 text-xs font-medium text-slate-500">Per Kamera scannen</p>
<DokumentScan
hochladenLaeuft={scanHochladen.isPending}
fehler={scanHochladen.isError ? (scanHochladen.error as Error).message : null}
onFertig={(dateien) => {
if (dateien.length === 0) return;
scanHochladen.mutate({
dateien,
typ: neuerDokumentTyp,
bezeichnung: `${new Date().toLocaleDateString("de-DE")} (${dateien.length} Seite${dateien.length > 1 ? "n" : ""})`,
});
}}
/>
</div>
</div>
<ul className="divide-y divide-slate-900/5">
{dokumente?.map((d) => (
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
<a href={dokumentUrl(pid, d.id)} target="_blank" rel="noreferrer" className="text-slate-700 underline">
{d.bezeichnung}
</a>
<div>
<a href={dokumentUrl(pid, d.id)} target="_blank" rel="noreferrer" className="text-slate-700 underline">
{d.bezeichnung}
</a>
<span className="ml-2 text-xs text-slate-400">{dokumentTypLabel(d.typ)}</span>
</div>
<button onClick={() => dokumentLoeschen.mutate(d.id)} className="text-xs text-red-600 underline">
Löschen
</button>
@@ -3,9 +3,18 @@
import { useEffect, useReducer, useState } from "react";
import { useRouter } from "next/navigation";
import { usePatientMutations } from "@/lib/patienten";
import { usePflegegradFragen, useSchaetzungMutation } from "@/lib/pflegegrad";
import { useAuth } from "@/lib/auth";
import { usePflegegradFragen, useSchaetzungMutation, useKrankenkasseLand } from "@/lib/pflegegrad";
import { pruefeVersichertennummer } from "@/lib/versichertennummer";
import { useKatalog, useRechnerMutation, type RechnerErgebnis } from "@/lib/katalog";
import { LEISTUNGSERKLAERUNGEN } from "@/lib/leistungserklaerungen";
import { api } from "@/lib/api";
import { usePlzSync } from "@/lib/plz";
import { DateWheelPicker } from "@/components/DateWheelPicker";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { InfoTooltip } from "@/components/InfoTooltip";
import { EgkScan } from "@/components/EgkScan";
import type { EgkScanErgebnis } from "@/lib/egk";
const STEP_TITEL = [
"Persönliche Daten",
@@ -13,6 +22,7 @@ const STEP_TITEL = [
"Krankenversicherung",
"Pflegesituation",
"Einschätzung & Pflegeperson",
"Leistungen",
"Übersicht",
];
@@ -40,11 +50,13 @@ type WizardState = {
data: WizardData;
antworten: Record<string, number>;
zielgruppe: string;
ausgewaehlteLeistungen: Set<string>;
};
const initialState: WizardState = {
step: 1,
q4sub: 0,
ausgewaehlteLeistungen: new Set(),
data: {
vorname: "",
nachname: "",
@@ -68,8 +80,9 @@ const initialState: WizardState = {
type Action =
| { type: "SET_FIELD"; field: keyof WizardData; value: string | number | null }
| { type: "SET_ZIELGRUPPE"; zg: string; total: number }
| { type: "SET_ANTWORT"; key: string; wert: number; total: number }
| { type: "SET_ZIELGRUPPE"; zg: string }
| { type: "SET_ANTWORT"; key: string; wert: number }
| { type: "TOGGLE_LEISTUNG"; id: string }
| { type: "NEXT"; total: number }
| { type: "BACK"; total: number };
@@ -77,14 +90,20 @@ function reducer(state: WizardState, action: Action): WizardState {
switch (action.type) {
case "SET_FIELD":
return { ...state, data: { ...state.data, [action.field]: action.value } };
case "SET_ZIELGRUPPE": {
const q4sub = state.q4sub < action.total - 1 ? state.q4sub + 1 : state.q4sub;
return { ...state, zielgruppe: action.zg, q4sub };
case "TOGGLE_LEISTUNG": {
const next = new Set(state.ausgewaehlteLeistungen);
next.has(action.id) ? next.delete(action.id) : next.add(action.id);
return { ...state, ausgewaehlteLeistungen: next };
}
case "SET_ZIELGRUPPE":
// Waehlt nur die Zielgruppe aus (sichtbar markiert) - der Wechsel zur
// naechsten Unterfrage passiert erst ueber den "Weiter"-Button unten,
// nicht mehr automatisch beim Klick (Nutzer-Feedback: Klick sprang
// sofort weiter, ohne dass die Markierung je sichtbar wurde).
return { ...state, zielgruppe: action.zg };
case "SET_ANTWORT": {
const antworten = { ...state.antworten, [action.key]: action.wert };
const q4sub = state.q4sub < action.total - 1 ? state.q4sub + 1 : state.q4sub;
return { ...state, antworten, q4sub };
return { ...state, antworten };
}
case "NEXT": {
if (state.step === 4 && state.q4sub < action.total - 1) {
@@ -120,19 +139,48 @@ const vor75Jahren = () => {
export default function NeuerPatientPage() {
const router = useRouter();
const { anlegen } = usePatientMutations();
const { meQuery } = useAuth();
const { data: fragenData, isLoading: fragenLoading } = usePflegegradFragen();
const schaetzung = useSchaetzungMutation();
const rechner = useRechnerMutation();
const [state, dispatch] = useReducer(reducer, initialState);
const total = 1 + (fragenData?.fragen.length ?? 5);
const [error, setError] = useState<string | null>(null);
const [berechtigungBestaetigt, setBerechtigungBestaetigt] = useState(false);
const [versichertennummerBeruehrt, setVersichertennummerBeruehrt] = useState(false);
const [hatEigeneEmail, setHatEigeneEmail] = useState(true);
const kontoEmail = meQuery.data?.email ?? null;
const eigeneEmailUmschalten = (checked: boolean) => {
setHatEigeneEmail(checked);
if (checked) {
dispatch({ type: "SET_FIELD", field: "email", value: "" });
} else if (kontoEmail) {
dispatch({ type: "SET_FIELD", field: "email", value: kontoEmail });
}
};
const krankenkasseLand = useKrankenkasseLand(state.data.krankenkasse);
const versichertennummerFehler = pruefeVersichertennummer(
state.data.versichertennummer,
krankenkasseLand,
state.data.versicherungsart
);
const next = () => {
if (state.step === 1 && (!state.data.vorname.trim() || !state.data.nachname.trim())) {
setError("Bitte Vor- und Nachname eintragen");
return;
}
if (state.step === 3 && versichertennummerFehler) {
// Fehler steht bereits direkt unter dem Feld (Step3) - hier nur
// sicherstellen, dass er beim Versuch weiterzugehen sichtbar wird,
// falls das Feld noch nie verlassen wurde.
setVersichertennummerBeruehrt(true);
return;
}
setError(null);
dispatch({ type: "NEXT", total });
};
@@ -145,6 +193,13 @@ export default function NeuerPatientPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.step]);
useEffect(() => {
if (state.step === 7) {
rechner.mutate({ pflegegrad: state.data.pflegegrad, zielgruppe: state.zielgruppe });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.step]);
useEffect(() => {
if (schaetzung.data && state.data.pflegegrad === 0) {
dispatch({ type: "SET_FIELD", field: "pflegegrad", value: schaetzung.data.vorschlag });
@@ -153,6 +208,15 @@ export default function NeuerPatientPage() {
}, [schaetzung.data]);
const speichern = async () => {
setError(null);
try {
await speichernIntern();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
};
const speichernIntern = async () => {
const patient = await anlegen.mutateAsync({
vorname: state.data.vorname,
nachname: state.data.nachname,
@@ -170,8 +234,21 @@ export default function NeuerPatientPage() {
notizen: state.data.notizen || undefined,
pflegegrad: state.data.pflegegrad,
zielgruppe: state.zielgruppe,
berechtigung_bestaetigt: berechtigungBestaetigt,
});
router.push(`/patienten/${patient.id}`);
let leistungenFehler = false;
if (state.ausgewaehlteLeistungen.size > 0) {
try {
await api(`/api/patienten/${patient.id}/leistungen/katalog`, {
method: "POST",
body: JSON.stringify({ katalog_ids: [...state.ausgewaehlteLeistungen] }),
});
} catch {
leistungenFehler = true;
}
}
router.push(`/patienten/${patient.id}${leistungenFehler ? "?leistungen_fehler=1" : ""}`);
};
return (
@@ -185,8 +262,23 @@ export default function NeuerPatientPage() {
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
{state.step === 1 && <Step1 state={state} dispatch={dispatch} />}
{state.step === 2 && <Step2 state={state} dispatch={dispatch} />}
{state.step === 3 && <Step3 state={state} dispatch={dispatch} />}
{state.step === 2 && (
<Step2
state={state}
dispatch={dispatch}
hatEigeneEmail={hatEigeneEmail}
onHatEigeneEmailChange={eigeneEmailUmschalten}
kontoEmail={kontoEmail}
/>
)}
{state.step === 3 && (
<Step3
state={state}
dispatch={dispatch}
fehler={versichertennummerBeruehrt ? versichertennummerFehler : null}
onVersichertennummerBlur={() => setVersichertennummerBeruehrt(true)}
/>
)}
{state.step === 4 &&
(fragenLoading || !fragenData ? (
<p className="text-slate-500">Lädt…</p>
@@ -196,7 +288,15 @@ export default function NeuerPatientPage() {
{state.step === 5 && (
<Step5 state={state} dispatch={dispatch} schaetzung={schaetzung.data} />
)}
{state.step === 6 && <Step6 state={state} />}
{state.step === 6 && <StepLeistungen state={state} dispatch={dispatch} />}
{state.step === 7 && (
<StepUebersicht
state={state}
rechner={rechner.data}
berechtigungBestaetigt={berechtigungBestaetigt}
onBerechtigungChange={setBerechtigungBestaetigt}
/>
)}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
@@ -209,7 +309,7 @@ export default function NeuerPatientPage() {
>
Zurück
</button>
{state.step < 6 ? (
{state.step < 7 ? (
<button
type="button"
onClick={next}
@@ -221,7 +321,7 @@ export default function NeuerPatientPage() {
<button
type="button"
onClick={speichern}
disabled={anlegen.isPending}
disabled={anlegen.isPending || !berechtigungBestaetigt}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Patient speichern
@@ -267,11 +367,13 @@ function Field({
value,
onChange,
required,
maxLength,
}: {
label: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
maxLength?: number;
}) {
return (
<div>
@@ -282,6 +384,7 @@ function Field({
<input
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={maxLength}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
@@ -295,11 +398,27 @@ function Step1({
state: WizardState;
dispatch: React.Dispatch<Action>;
}) {
const egkUebernehmen = (ergebnis: EgkScanErgebnis) => {
if (ergebnis.vorname) dispatch({ type: "SET_FIELD", field: "vorname", value: ergebnis.vorname });
if (ergebnis.nachname) dispatch({ type: "SET_FIELD", field: "nachname", value: ergebnis.nachname });
if (ergebnis.geburtsdatum)
dispatch({ type: "SET_FIELD", field: "geburtsdatum", value: ergebnis.geburtsdatum });
if (ergebnis.krankenkasse)
dispatch({ type: "SET_FIELD", field: "krankenkasse", value: ergebnis.krankenkasse });
if (ergebnis.versichertennummer)
dispatch({
type: "SET_FIELD",
field: "versichertennummer",
value: ergebnis.versichertennummer,
});
};
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-slate-700">
Wie heißt die Person, die Sie anmelden möchten?
</p>
<EgkScan onErgebnis={egkUebernehmen} />
<Field
label="Vorname"
required
@@ -325,7 +444,21 @@ function Step1({
);
}
function Step2({ state, dispatch }: { state: WizardState; dispatch: React.Dispatch<Action> }) {
function Step2({
state,
dispatch,
hatEigeneEmail,
onHatEigeneEmailChange,
kontoEmail,
}: {
state: WizardState;
dispatch: React.Dispatch<Action>;
hatEigeneEmail: boolean;
onHatEigeneEmailChange: (checked: boolean) => void;
kontoEmail: string | null;
}) {
usePlzSync(state.data.plz, (v) => dispatch({ type: "SET_FIELD", field: "ort", value: v }));
return (
<div className="flex flex-col gap-3">
<Field
@@ -339,6 +472,7 @@ function Step2({ state, dispatch }: { state: WizardState; dispatch: React.Dispat
label="PLZ"
value={state.data.plz}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "plz", value: v })}
maxLength={5}
/>
</div>
<div className="flex-1">
@@ -354,27 +488,78 @@ function Step2({ state, dispatch }: { state: WizardState; dispatch: React.Dispat
value={state.data.telefon}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "telefon", value: v })}
/>
<Field
label="E-Mail"
value={state.data.email}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "email", value: v })}
/>
<label className="flex items-center gap-2 text-xs text-slate-600">
<input
type="checkbox"
checked={hatEigeneEmail}
onChange={(e) => onHatEigeneEmailChange(e.target.checked)}
/>
Patient hat eine eigene E-Mail-Adresse
</label>
{!hatEigeneEmail && kontoEmail && (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
Es wird stattdessen Ihre eigene E-Mail-Adresse verwendet:{" "}
<span className="font-medium text-slate-800">{kontoEmail}</span>
</div>
)}
{(hatEigeneEmail || !kontoEmail) && (
<Field
label="E-Mail"
value={state.data.email}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "email", value: v })}
/>
)}
{!hatEigeneEmail && !kontoEmail && (
<p className="text-xs text-slate-500">
Für Ihr Konto ist keine E-Mail-Adresse hinterlegt – bitte hier eine E-Mail-Adresse
eintragen, unter der der Patient bzw. Sie erreichbar sind.
</p>
)}
</div>
);
}
function Step3({ state, dispatch }: { state: WizardState; dispatch: React.Dispatch<Action> }) {
function Step3({
state,
dispatch,
fehler,
onVersichertennummerBlur,
}: {
state: WizardState;
dispatch: React.Dispatch<Action>;
fehler: string | null;
onVersichertennummerBlur: () => void;
}) {
return (
<div className="flex flex-col gap-3">
<KrankenkasseAutocomplete
value={state.data.krankenkasse}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "krankenkasse", value: v })}
/>
<Field
label="Versichertennummer"
value={state.data.versichertennummer}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "versichertennummer", value: v })}
/>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Versichertennummer</label>
<input
value={state.data.versichertennummer}
onChange={(e) =>
dispatch({ type: "SET_FIELD", field: "versichertennummer", value: e.target.value })
}
onBlur={onVersichertennummerBlur}
className={`w-full rounded-lg border px-3 py-2 text-sm ${
fehler ? "border-red-400" : "border-slate-300"
}`}
/>
{fehler ? (
<p className="mt-1 text-xs text-red-600">{fehler}</p>
) : (
<p className="mt-1 text-xs text-slate-400">
Format hängt von der Krankenkasse ab (DE z. B. A123456789).
</p>
)}
</div>
<Field
label="Rentenversicherungsnummer (optional)"
value={state.data.rentenversicherungsnummer}
@@ -429,7 +614,7 @@ function Step4({
<button
key={o.zg}
type="button"
onClick={() => dispatch({ type: "SET_ZIELGRUPPE", zg: o.zg, total })}
onClick={() => dispatch({ type: "SET_ZIELGRUPPE", zg: o.zg })}
className={`rounded-lg border px-3 py-2 text-left text-sm ${
state.zielgruppe === o.zg
? "border-slate-800 bg-slate-50"
@@ -453,9 +638,7 @@ function Step4({
<button
key={o.wert}
type="button"
onClick={() =>
dispatch({ type: "SET_ANTWORT", key: frage.key, wert: o.wert, total })
}
onClick={() => dispatch({ type: "SET_ANTWORT", key: frage.key, wert: o.wert })}
className={`rounded-lg border px-3 py-2 text-left text-sm ${
state.antworten[frage.key] === o.wert
? "border-slate-800 bg-slate-50"
@@ -540,7 +723,71 @@ function Step5({
);
}
function Step6({ state }: { state: WizardState }) {
function StepLeistungen({ state, dispatch }: { state: WizardState; dispatch: React.Dispatch<Action> }) {
const { data: katalog, isLoading } = useKatalog({
zielgruppe: state.zielgruppe,
pflegegrad: state.data.pflegegrad,
});
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-slate-700">
Welche Leistungen aus dem Leistungskatalog könnten für{" "}
{state.data.vorname || "diese Person"} infrage kommen?
</p>
<p className="text-xs text-slate-500">
Vorauswahl passend zu Pflegegrad {state.data.pflegegrad === 0 ? "–" : state.data.pflegegrad}{" "}
und der gewählten Zielgruppe – nichts ausgewählt ist auch in Ordnung, lässt sich später auf
der Patientenseite jederzeit nachtragen.
</p>
{isLoading ? (
<p className="text-sm text-slate-500">Lädt…</p>
) : (
<div className="flex max-h-96 flex-col gap-2 overflow-y-auto">
{katalog?.items.map((k) => (
<label
key={k.id}
className={`flex items-start gap-2 rounded-lg border p-2.5 text-sm ${
state.ausgewaehlteLeistungen.has(k.id)
? "border-slate-800 bg-slate-50"
: "border-slate-200"
}`}
>
<input
type="checkbox"
className="mt-0.5"
checked={state.ausgewaehlteLeistungen.has(k.id)}
onChange={() => dispatch({ type: "TOGGLE_LEISTUNG", id: k.id })}
/>
<span>
<span className="block font-medium text-slate-800">{k.name}</span>
<span className="block text-xs text-slate-500">{k.kurz}</span>
<span className="block text-xs text-slate-400">
{k.rechtsgrundlage} · {k.betrag}
</span>
</span>
</label>
))}
{katalog && katalog.items.length === 0 && (
<p className="text-sm text-slate-500">Keine passenden Leistungen gefunden.</p>
)}
</div>
)}
</div>
);
}
function StepUebersicht({
state,
rechner,
berechtigungBestaetigt,
onBerechtigungChange,
}: {
state: WizardState;
rechner?: RechnerErgebnis;
berechtigungBestaetigt: boolean;
onBerechtigungChange: (v: boolean) => void;
}) {
const d = state.data;
return (
<div className="flex flex-col gap-2 text-sm">
@@ -551,6 +798,79 @@ function Step6({ state }: { state: WizardState }) {
<SummaryRow label="Krankenkasse" value={d.krankenkasse || "–"} />
<SummaryRow label="Pflegegrad" value={d.pflegegrad === 0 ? "keiner" : String(d.pflegegrad)} />
<SummaryRow label="Pflegeperson" value={d.pflegeperson || "–"} />
<SummaryRow label="Ausgewählte Leistungen" value={String(state.ausgewaehlteLeistungen.size)} />
{rechner && (
<div className="mt-2 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
<p className="mb-2 font-semibold">
Geschätzter Geld-Vorteil laut Pflegegrad {d.pflegegrad === 0 ? "–" : d.pflegegrad}
</p>
<p className="mb-1 font-medium text-emerald-900">Monatlich</p>
<ul className="mb-2 flex flex-col gap-0.5">
<li className="flex items-center">
Pflegegeld: {rechner.pflegegeld} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.pflegegeld} />
</li>
<li className="flex items-center">
Pflegesachleistung: {rechner.sachleistung} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.sachleistung} />
</li>
<li className="flex items-center">
Entlastungsbetrag: {rechner.entlastungsbetrag} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.entlastungsbetrag} />
</li>
<li className="flex items-center">
Pflegehilfsmittel: {rechner.hilfsmittel} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.hilfsmittel} />
</li>
</ul>
<p className="mb-1 font-medium text-emerald-900">Jährlich</p>
<ul className="mb-2 flex flex-col gap-0.5">
<li className="flex items-center">
Verhinderungspflege: {rechner.verhinderungspflege} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.verhinderungspflege} />
</li>
<li className="flex items-center">
Kurzzeitpflege: {rechner.kurzzeitpflege} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.kurzzeitpflege} />
</li>
</ul>
{rechner.wohnumfeld > 0 && (
<>
<p className="mb-1 font-medium text-emerald-900">Einmalig</p>
<p className="mb-2 flex items-center">
Wohnumfeldverbesserung: {rechner.wohnumfeld} €
<InfoTooltip text={LEISTUNGSERKLAERUNGEN.wohnumfeld} />
</p>
</>
)}
<p className="mt-1 text-[11px] text-emerald-700">
Unverbindliche Schätzung – Pflegegeld und Pflegesachleistung schließen sich in der Regel
gegenseitig aus (bzw. sind anteilig kombinierbar), nicht addieren.
</p>
</div>
)}
<label className="mt-3 flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
<input
type="checkbox"
required
checked={berechtigungBestaetigt}
onChange={(e) => onBerechtigungChange(e.target.checked)}
className="mt-0.5"
/>
<span>
Ich bin berechtigt, im Namen von {d.vorname || "der pflegebedürftigen Person"}{" "}
{d.nachname} deren Daten in MeinPflegeFuchs einzugeben und zu pflegen. Hierfür liegt eine
Einwilligung der betroffenen Person, eine tatsächliche Pflege- und Fürsorgebeziehung
oder eine entsprechende rechtliche Vertretungsbefugnis (z. B. Vorsorgevollmacht,
rechtliche Betreuung) vor.
</span>
</label>
</div>
);
}
@@ -0,0 +1,520 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { usePatienten, type Patient } from "@/lib/patienten";
import { useAntragMutationen, useAntraegeFuerPatient, pdfUrl } from "@/lib/briefe";
import { useAuth } from "@/lib/auth";
import {
usePatientenverfuegungTextbausteine,
MASSNAHMEN_REIHENFOLGE,
type Option,
} from "@/lib/patientenverfuegung";
const SCHRITTE = ["Situationen", "Maßnahmen", "Ort & Beistand", "Vertretung & Organspende", "Beratung & Abschluss"];
function RadioListe({
optionen,
value,
onChange,
}: {
optionen: Option[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-col gap-2">
{optionen.map((o) => (
<label
key={o.id}
className={`cursor-pointer rounded-xl border p-3 text-sm ${
value === o.id ? "border-slate-800 bg-slate-50" : "border-slate-200 bg-white"
}`}
>
<div className="flex items-start gap-2">
<input
type="radio"
checked={value === o.id}
onChange={() => onChange(o.id)}
className="mt-0.5"
/>
<span>{o.label}</span>
</div>
{value === o.id && o.zusatz_text && (
<p className="mt-2 ml-6 text-xs text-slate-500">
Zusätzlich wird aufgenommen: „{o.zusatz_text}“
</p>
)}
</label>
))}
</div>
);
}
export default function PatientenverfuegungPage() {
const { data: patienten } = usePatienten();
const { meQuery } = useAuth();
const { data: tb, isLoading: tbLaedt } = usePatientenverfuegungTextbausteine();
const [patientId, setPatientId] = useState<number | null>(null);
const { data: antraege } = useAntraegeFuerPatient(patientId ?? 0);
const { patientenverfuegungErstellen } = useAntragMutationen(patientId ?? 0);
const [step, setStep] = useState(0);
const [massnahmeSub, setMassnahmeSub] = useState(0);
const [situationen, setSituationen] = useState<string[]>([]);
const [situationEigene, setSituationEigene] = useState("");
const [massnahmen, setMassnahmen] = useState<Record<string, string>>({});
const [schmerzbehandlungZusatz, setSchmerzbehandlungZusatz] = useState(false);
const [wiederbelebungA, setWiederbelebungA] = useState("");
const [wiederbelebungB, setWiederbelebungB] = useState("keine_regelung");
const [ort, setOrt] = useState("keine_angabe");
const [beistandPersonen, setBeistandPersonen] = useState("");
const [beistandKirche, setBeistandKirche] = useState("");
const [hospizlicherBeistand, setHospizlicherBeistand] = useState(false);
const [schweigepflichtPersonen, setSchweigepflichtPersonen] = useState("");
const [vertrauensperson, setVertrauensperson] = useState("bevollmaechtigter");
const [vertrauenspersonAndereName, setVertrauenspersonAndereName] = useState("");
const [vollmachtName, setVollmachtName] = useState("");
const [betreuerName, setBetreuerName] = useState("");
const [organspende, setOrganspende] = useState("keine_angabe");
const [organspendeKonflikt, setOrganspendeKonflikt] = useState("patientenverfuegung_vor");
const [aufklaerungsverzicht, setAufklaerungsverzicht] = useState(false);
const [informiertBei, setInformiertBei] = useState("");
const [beratenDurch, setBeratenDurch] = useState("");
const [aerztlicheBestaetigungFreifeld, setAerztlicheBestaetigungFreifeld] = useState(true);
const [ausstellungsort, setAusstellungsort] = useState("");
const hatVollmacht = useMemo(
() => (antraege ?? []).some((a) => a.typ === "vollmacht"),
[antraege]
);
useEffect(() => {
if (hatVollmacht && !vollmachtName && meQuery.data) {
setVollmachtName(meQuery.data.name || meQuery.data.benutzername);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hatVollmacht, meQuery.data]);
const massnahmenSubReihenfolge = ["lebenserhaltung", "wiederbelebung", ...MASSNAHMEN_REIHENFOLGE];
const situationAuswaehlen = (id: string) =>
setSituationen((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const kannWeiter = () => {
if (step === 0) return situationen.length > 0 || situationEigene.trim().length > 0;
if (step === 1) return massnahmeSub < massnahmenSubReihenfolge.length - 1 || !!wiederbelebungA;
return true;
};
const weiter = () => {
if (step === 1 && massnahmeSub < massnahmenSubReihenfolge.length - 1) {
setMassnahmeSub((s) => s + 1);
return;
}
setStep((s) => Math.min(s + 1, SCHRITTE.length - 1));
if (step === 1) setMassnahmeSub(0);
};
const zurueck = () => {
if (step === 1 && massnahmeSub > 0) {
setMassnahmeSub((s) => s - 1);
return;
}
setStep((s) => Math.max(s - 1, 0));
};
const erstellen = () => {
if (!patientId) return;
patientenverfuegungErstellen.mutate({
situationen,
situation_eigene: situationEigene || undefined,
massnahmen,
schmerzbehandlung_zusatz: schmerzbehandlungZusatz,
wiederbelebung_a: wiederbelebungA,
wiederbelebung_b: wiederbelebungB,
ort,
beistand_personen: beistandPersonen || undefined,
beistand_kirche: beistandKirche || undefined,
hospizlicher_beistand: hospizlicherBeistand,
schweigepflicht_personen: schweigepflichtPersonen || undefined,
vertrauensperson,
vertrauensperson_andere_name: vertrauenspersonAndereName || undefined,
vollmacht_name: vollmachtName || undefined,
betreuer_name: betreuerName || undefined,
organspende,
organspende_konflikt: organspende === "zustimmung" ? organspendeKonflikt : undefined,
aufklaerungsverzicht,
informiert_bei: informiertBei || undefined,
beraten_durch: beratenDurch || undefined,
aerztliche_bestaetigung_freifeld: aerztlicheBestaetigungFreifeld,
ausstellungsort: ausstellungsort || undefined,
});
};
if (patientenverfuegungErstellen.data) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold text-slate-800">Patientenverfügung erstellt ✓</h1>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<p className="mb-3 text-sm text-emerald-800">
Bitte ausdrucken, vom Patienten eigenhändig unterschreiben lassen und griffbereit
aufbewahren (z. B. bei den Patientenunterlagen, eine Kopie beim Bevollmächtigten). Eine
Patientenverfügung entfaltet nur Wirkung, wenn sie im Ernstfall auch gefunden wird –
eine Registrierung im Zentralen Vorsorgeregister (vorsorgeregister.de) kann sinnvoll sein.
</p>
<div className="flex gap-2">
<a
href={pdfUrl(patientId!, patientenverfuegungErstellen.data.id)}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white"
>
PDF öffnen
</a>
<button
type="button"
onClick={() => patientenverfuegungErstellen.reset()}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-600"
>
Neu erstellen
</button>
</div>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-1 text-2xl font-semibold text-slate-800">Patientenverfügung</h1>
<p className="mb-4 text-sm text-slate-500">
Ein Interview führt Sie durch die wichtigsten Festlegungen. Formulierungen basieren auf den
offiziellen Textbausteinen des Bundesministeriums der Justiz und ersetzen keine rechtliche
oder ärztliche Beratung.
</p>
<div className="mb-4 rounded-2xl border border-slate-200 bg-white p-4">
<label className="mb-1 block text-xs font-medium text-slate-500">Patient</label>
<select
value={patientId ?? ""}
onChange={(e) => setPatientId(Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">– wählen –</option>
{patienten?.map((p: Patient) => (
<option key={p.id} value={p.id}>
{p.vorname} {p.nachname}
</option>
))}
</select>
</div>
{!patientId || tbLaedt || !tb ? (
<p className="text-sm text-slate-500">
{!patientId ? "Bitte zuerst einen Patienten auswählen." : "Lädt…"}
</p>
) : (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="mb-4 flex items-center gap-1.5">
{SCHRITTE.map((titel, i) => (
<div
key={titel}
className={`h-1.5 flex-1 rounded-full ${i <= step ? "bg-slate-800" : "bg-slate-200"}`}
title={titel}
/>
))}
</div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
{SCHRITTE[step]}
{step === 1 && ` – ${massnahmeSub + 1}/${massnahmenSubReihenfolge.length}`}
</p>
{step === 0 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-slate-600">
Für welche Situationen soll diese Verfügung gelten? (mindestens eine auswählen)
</p>
{tb.situationen.map((s) => (
<label
key={s.id}
className={`flex cursor-pointer items-start gap-2 rounded-xl border p-3 text-sm ${
situationen.includes(s.id) ? "border-slate-800 bg-slate-50" : "border-slate-200"
}`}
>
<input
type="checkbox"
checked={situationen.includes(s.id)}
onChange={() => situationAuswaehlen(s.id)}
className="mt-0.5"
/>
<span>{s.label}</span>
</label>
))}
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Eigene Beschreibung einer weiteren Situation (optional)
</label>
<textarea
value={situationEigene}
onChange={(e) => setSituationEigene(e.target.value)}
rows={3}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
</div>
)}
{step === 1 && (
<div className="flex flex-col gap-3">
{massnahmenSubReihenfolge[massnahmeSub] === "wiederbelebung" ? (
<>
<p className="text-sm font-medium text-slate-700">
In den zuvor beschriebenen Situationen wünsche ich
</p>
<RadioListe
optionen={tb.wiederbelebung_a_optionen}
value={wiederbelebungA}
onChange={setWiederbelebungA}
/>
<p className="mt-3 text-sm font-medium text-slate-700">
Unabhängig davon, in allen Fällen eines Kreislaufstillstands oder Atemversagens
(optional)
</p>
<RadioListe
optionen={tb.wiederbelebung_b_optionen}
value={wiederbelebungB}
onChange={setWiederbelebungB}
/>
</>
) : (
(() => {
const key = massnahmenSubReihenfolge[massnahmeSub];
const m = tb.massnahmen[key];
return (
<>
<p className="text-sm font-medium text-slate-700">{m.label}</p>
<p className="text-xs text-slate-500">{m.einleitung}</p>
<RadioListe
optionen={m.optionen}
value={massnahmen[key] ?? ""}
onChange={(v) => setMassnahmen((s) => ({ ...s, [key]: v }))}
/>
{m.zusatz && (
<label className="mt-2 flex items-start gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={schmerzbehandlungZusatz}
onChange={(e) => setSchmerzbehandlungZusatz(e.target.checked)}
className="mt-0.5"
/>
<span>{m.zusatz.label}</span>
</label>
)}
</>
);
})()
)}
</div>
)}
{step === 2 && (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-slate-700">Ort der Behandlung</p>
<RadioListe optionen={tb.ort_optionen} value={ort} onChange={setOrt} />
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Beistand durch folgende Personen (optional)
</label>
<input
value={beistandPersonen}
onChange={(e) => setBeistandPersonen(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Beistand durch Kirche/Weltanschauungsgemeinschaft (optional)
</label>
<input
value={beistandKirche}
onChange={(e) => setBeistandKirche(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={hospizlicherBeistand}
onChange={(e) => setHospizlicherBeistand(e.target.checked)}
/>
Hospizlichen Beistand wünschen
</label>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Entbindung von der ärztlichen Schweigepflicht gegenüber (optional)
</label>
<input
value={schweigepflichtPersonen}
onChange={(e) => setSchweigepflichtPersonen(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
</div>
)}
{step === 3 && (
<div className="flex flex-col gap-3">
{hatVollmacht && (
<p className="rounded-lg bg-slate-50 p-2 text-xs text-slate-500">
Für diesen Patienten ist bereits eine Vollmacht hinterlegt.
</p>
)}
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Vorsorgevollmacht erteilt an (optional)
</label>
<input
value={vollmachtName}
onChange={(e) => setVollmachtName(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Gewünschte Betreuerin/gewünschter Betreuer (optional)
</label>
<input
value={betreuerName}
onChange={(e) => setBetreuerName(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Bei Uneinigkeit soll die Auffassung dieser Person besonderes Gewicht haben
</label>
<select
value={vertrauensperson}
onChange={(e) => setVertrauensperson(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
{tb.vertrauensperson_optionen.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{vertrauensperson === "andere" && (
<input
value={vertrauenspersonAndereName}
onChange={(e) => setVertrauenspersonAndereName(e.target.value)}
placeholder="Name der Person"
className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
)}
</div>
<p className="mt-2 text-sm font-medium text-slate-700">Organspende</p>
<RadioListe optionen={tb.organspende_optionen} value={organspende} onChange={setOrganspende} />
{organspende === "zustimmung" && (
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Falls Organspende und diese Patientenverfügung im Konfliktfall widersprüchlich wären
</label>
<RadioListe
optionen={tb.organspende_konflikt_optionen}
value={organspendeKonflikt}
onChange={setOrganspendeKonflikt}
/>
</div>
)}
</div>
)}
{step === 4 && (
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Informiert vor Erstellung bei/durch (optional)
</label>
<input
value={informiertBei}
onChange={(e) => setInformiertBei(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Beraten lassen durch (optional)
</label>
<input
value={beratenDurch}
onChange={(e) => setBeratenDurch(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<label className="flex items-start gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={aufklaerungsverzicht}
onChange={(e) => setAufklaerungsverzicht(e.target.checked)}
className="mt-0.5"
/>
<span>Ich verzichte ausdrücklich auf eine (weitere) ärztliche Aufklärung zu den hier getroffenen Entscheidungen</span>
</label>
<label className="flex items-start gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={aerztlicheBestaetigungFreifeld}
onChange={(e) => setAerztlicheBestaetigungFreifeld(e.target.checked)}
className="mt-0.5"
/>
<span>Freifeld für eine spätere ärztliche Bestätigung der Einwilligungsfähigkeit einfügen</span>
</label>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Ausstellungsort</label>
<input
value={ausstellungsort}
onChange={(e) => setAusstellungsort(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
</div>
)}
<div className="mt-5 flex justify-between">
<button
onClick={zurueck}
disabled={step === 0 && massnahmeSub === 0}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-600 disabled:opacity-40"
>
Zurück
</button>
{step === SCHRITTE.length - 1 ? (
<button
onClick={erstellen}
disabled={patientenverfuegungErstellen.isPending || !kannWeiter()}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Patientenverfügung erstellen
</button>
) : (
<button
onClick={weiter}
disabled={!kannWeiter()}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Weiter
</button>
)}
</div>
{patientenverfuegungErstellen.isError && (
<p className="mt-2 text-sm text-red-600">
{(patientenverfuegungErstellen.error as Error).message}
</p>
)}
</div>
)}
</div>
);
}
@@ -2,6 +2,8 @@
import { useState } from "react";
import { useRechnerMutation } from "@/lib/katalog";
import { LEISTUNGSERKLAERUNGEN } from "@/lib/leistungserklaerungen";
import { InfoTooltip } from "@/components/InfoTooltip";
const ZIELGRUPPEN = [
{ value: "alterspflege", label: "Altersbedingt" },
@@ -79,14 +81,25 @@ export default function RechnerPage() {
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<ul className="divide-y divide-slate-100">
{ZEILEN.map((z) => (
<li key={z.key} className="flex justify-between py-2 text-sm">
<span className="text-slate-600">{z.label}</span>
<li key={z.key} className="flex items-center justify-between py-2 text-sm">
<span className="flex items-center text-slate-600">
{z.label}
<InfoTooltip text={LEISTUNGSERKLAERUNGEN[z.key]} />
</span>
<span className="font-medium text-slate-800">
{data[z.key]} {z.einheit}
</span>
</li>
))}
</ul>
{data.pflegepauschbetrag > 0 && (
<p className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-2.5 text-xs text-emerald-800">
<strong>Steuertipp:</strong> Bei persönlicher, unentgeltlicher Pflege im häuslichen Umfeld
lässt sich zusätzlich der Pflegepauschbetrag (§ 33b Abs. 6 EStG) von{" "}
<strong>{data.pflegepauschbetrag} € pro Jahr</strong> als außergewöhnliche Belastung geltend
machen – keine Steuerberatung, bitte im Einzelfall prüfen.
</p>
)}
<p className="mt-3 text-xs text-slate-500">
{data.relevant_count} passende Leistungen im Katalog gefunden.
</p>
@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { IconStar, IconStarFilled, IconTrash } from "@tabler/icons-react";
import { ApiError } from "@/lib/api";
import {
UmkreissucheTreffer,
useUmkreissuche,
useUmkreissucheFavoriten,
useUmkreissucheFavoritMutationen,
useUmkreissucheKategorien,
} from "@/lib/umkreissuche";
import { Collapsible } from "@/components/Collapsible";
const RADIEN = [5, 10, 15, 25, 50];
function Kontaktzeilen({ telefon, email, webseite }: { telefon: string | null; email: string | null; webseite: string | null }) {
return (
<div className="mt-1 flex flex-wrap gap-3 text-sm">
{telefon && (
<a href={`tel:${telefon}`} className="text-blue-600 hover:underline">
{telefon}
</a>
)}
{email && (
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
{email}
</a>
)}
{webseite && (
<a href={webseite} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline">
Webseite
</a>
)}
</div>
);
}
export default function UmkreissuchePage() {
const { data: kategorien } = useUmkreissucheKategorien();
const [plz, setPlz] = useState("");
const [gesuchtePlz, setGesuchtePlz] = useState("");
const [kategorie, setKategorie] = useState("");
const [radiusKm, setRadiusKm] = useState(15);
const { data, isFetching, isError, error } = useUmkreissuche(gesuchtePlz, kategorie, radiusKm);
const { data: favoriten } = useUmkreissucheFavoriten();
const { hinzufuegen, entfernen } = useUmkreissucheFavoritMutationen();
const suchenAktiv = /^\d{5}$/.test(plz) && kategorie.length > 0;
const favoritZu = (t: UmkreissucheTreffer) =>
favoriten?.find((f) => f.osm_type === t.osm_type && f.osm_id === t.osm_id);
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="mb-1 text-2xl font-semibold text-slate-800">Umkreissuche</h1>
<p className="mb-4 text-sm text-slate-500">
Findet lokale Dienstleister in der Nähe einer Postleitzahl über OpenStreetMap-Daten. Die
Datenqualität hängt davon ab, wie vollständig ein Anbieter dort erfasst ist – Angaben bitte
vor Kontaktaufnahme gegenprüfen.
</p>
{favoriten && favoriten.length > 0 && (
<div className="mb-4">
<Collapsible title="Meine Favoriten" count={favoriten.length} defaultOpen={false}>
<div className="flex flex-col gap-2">
{favoriten.map((f) => (
<div key={f.id} className="rounded-xl border border-slate-200 p-3">
<div className="mb-1 flex items-start justify-between gap-2">
<span className="font-medium text-slate-800">{f.name}</span>
<button
onClick={() => entfernen.mutate(f.id)}
disabled={entfernen.isPending}
className="shrink-0 text-slate-400 hover:text-red-600"
aria-label="Favorit entfernen"
>
<IconTrash size={16} />
</button>
</div>
{(f.strasse || f.plz || f.ort) && (
<p className="text-sm text-slate-600">
{[f.strasse, [f.plz, f.ort].filter(Boolean).join(" ")].filter(Boolean).join(", ")}
</p>
)}
<Kontaktzeilen telefon={f.telefon} email={f.email} webseite={f.webseite} />
</div>
))}
</div>
</Collapsible>
</div>
)}
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Postleitzahl</label>
<input
type="text"
inputMode="numeric"
maxLength={5}
value={plz}
onChange={(e) => setPlz(e.target.value.replace(/\D/g, ""))}
placeholder="z.B. 10115"
className="w-28 rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Kategorie</label>
<select
value={kategorie}
onChange={(e) => setKategorie(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">Bitte wählen…</option>
{kategorien?.map((k) => (
<option key={k.kategorie} value={k.kategorie}>
{k.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Umkreis</label>
<select
value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
{RADIEN.map((r) => (
<option key={r} value={r}>
{r} km
</option>
))}
</select>
</div>
<button
onClick={() => setGesuchtePlz(plz)}
disabled={!suchenAktiv || isFetching}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Suchen
</button>
</div>
{isFetching && <p className="text-slate-500">Suche läuft…</p>}
{isError && (
<p className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error instanceof ApiError ? error.message : "Die Suche ist fehlgeschlagen."}
</p>
)}
{data && !isFetching && (
<>
<p className="mb-2 text-xs text-slate-500">
{data.treffer.length} Treffer im Umkreis von {data.radius_km} km um {data.plz}
</p>
{data.treffer.length === 0 && (
<p className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-500">
Keine Einträge gefunden – ggf. größeren Umkreis wählen.
</p>
)}
<div className="flex flex-col gap-2">
{data.treffer.map((t, i) => {
const favorit = favoritZu(t);
return (
<div key={i} className="rounded-xl border border-slate-200 bg-white p-4">
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="font-medium text-slate-800">{t.name}</span>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-slate-500">{t.entfernung_km} km</span>
<button
onClick={() =>
favorit ? entfernen.mutate(favorit.id) : hinzufuegen.mutate(t)
}
disabled={hinzufuegen.isPending || entfernen.isPending}
className={favorit ? "text-amber-500" : "text-slate-300 hover:text-amber-500"}
aria-label={favorit ? "Favorit entfernen" : "Als Favorit merken"}
>
{favorit ? <IconStarFilled size={18} /> : <IconStar size={18} />}
</button>
</div>
</div>
{(t.strasse || t.plz || t.ort) && (
<p className="text-sm text-slate-600">
{[t.strasse, [t.plz, t.ort].filter(Boolean).join(" ")].filter(Boolean).join(", ")}
</p>
)}
<Kontaktzeilen telefon={t.telefon} email={t.email} webseite={t.webseite} />
</div>
);
})}
</div>
</>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { usePatienten, type Patient } from "@/lib/patienten";
import { useAdressaenderungMutation, pdfUrl } from "@/lib/briefe";
import { useAuth } from "@/lib/auth";
import { Collapsible } from "@/components/Collapsible";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { RentenversicherungAutocomplete } from "@/components/RentenversicherungAutocomplete";
@@ -11,6 +12,7 @@ import { getAccent } from "@/lib/theme";
export default function UmmeldungPage() {
const { data: patienten } = usePatienten();
const { meQuery } = useAuth();
const [patientId, setPatientId] = useState<number | null>(null);
const [patient, setPatient] = useState<Patient | null>(null);
@@ -19,6 +21,7 @@ export default function UmmeldungPage() {
const [neueOrt, setNeueOrt] = useState("");
const [gueltigAb, setGueltigAb] = useState<string | null>(null);
const [absenderTelefon, setAbsenderTelefon] = useState("");
const [absenderEmail, setAbsenderEmail] = useState("");
const [kkBenachrichtigen, setKkBenachrichtigen] = useState(true);
const [krankenkasse, setKrankenkasse] = useState("");
@@ -35,6 +38,9 @@ export default function UmmeldungPage() {
setPatient(p);
setKrankenkasse(p.krankenkasse ?? "");
setAbsenderTelefon(p.telefon ?? "");
// Patient hat meist keine eigene E-Mail-Adresse - dann die des
// eingeloggten (bevollmächtigten) Nutzers vorschlagen, statt leer zu lassen.
setAbsenderEmail(p.email ?? meQuery.data?.email ?? "");
setNeueStrasse(p.strasse ?? "");
setNeuePlz(p.plz ?? "");
setNeueOrt(p.ort ?? "");
@@ -49,6 +55,7 @@ export default function UmmeldungPage() {
neue_ort: neueOrt,
gueltig_ab: gueltigAb,
absender_telefon: absenderTelefon,
absender_email: absenderEmail || undefined,
krankenkasse_benachrichtigen: kkBenachrichtigen,
krankenkasse: krankenkasse || undefined,
rentenversicherung_benachrichtigen: rvBenachrichtigen,
@@ -66,7 +73,7 @@ export default function UmmeldungPage() {
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-1 text-2xl font-semibold text-slate-800">Ummeldung bei Umzug</h1>
<p className="mb-4 text-sm text-slate-500">
Neue Adresse einmal angeben – Pflegepilot aktualisiert den Patienten und erstellt direkt die
Neue Adresse einmal angeben – MeinPflegeFuchs aktualisiert den Patienten und erstellt direkt die
Mitteilungsschreiben an Krankenkasse und Rentenversicherung.
</p>
@@ -123,6 +130,12 @@ export default function UmmeldungPage() {
onChange={(e) => setAbsenderTelefon(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<input
placeholder="E-Mail (für Briefkopf, optional)"
value={absenderEmail}
onChange={(e) => setAbsenderEmail(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<div className="mt-2 rounded-lg border border-slate-200 p-3">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useState } from "react";
import { usePatienten, type Patient } from "@/lib/patienten";
import { useAntragMutationen, pdfUrl } from "@/lib/briefe";
import { useAuth } from "@/lib/auth";
import { Collapsible } from "@/components/Collapsible";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { getAccent } from "@/lib/theme";
export default function VollmachtPage() {
const { data: patienten } = usePatienten();
const { meQuery } = useAuth();
const [patientId, setPatientId] = useState<number | null>(null);
const [bevollmaechtigterName, setBevollmaechtigterName] = useState("");
const [bevollmaechtigterStrasse, setBevollmaechtigterStrasse] = useState("");
const [bevollmaechtigterPlzOrt, setBevollmaechtigterPlzOrt] = useState("");
const [krankenkasse, setKrankenkasse] = useState("");
const [ort, setOrt] = useState("");
const [formOffen, setFormOffen] = useState(true);
const { vollmachtErstellen } = useAntragMutationen(patientId ?? 0);
useEffect(() => {
if (vollmachtErstellen.isSuccess) setFormOffen(false);
}, [vollmachtErstellen.isSuccess]);
useEffect(() => {
const me = meQuery.data;
if (!me) return;
setBevollmaechtigterName((v) => v || me.name || me.benutzername);
setBevollmaechtigterStrasse((v) => v || me.rg_strasse || "");
setBevollmaechtigterPlzOrt((v) => v || [me.rg_plz, me.rg_ort].filter(Boolean).join(" "));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data]);
const patientWaehlen = (pid: number) => {
setPatientId(pid);
const p = patienten?.find((x: Patient) => x.id === pid);
if (!p) return;
if (p.krankenkasse) setKrankenkasse(p.krankenkasse);
setOrt((v) => v || p.ort || "");
};
const erstellen = () => {
if (!patientId) return;
vollmachtErstellen.mutate({
bevollmaechtigter_name: bevollmaechtigterName,
bevollmaechtigter_strasse: bevollmaechtigterStrasse,
bevollmaechtigter_plz_ort: bevollmaechtigterPlzOrt,
krankenkasse,
ort,
});
};
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-1 text-2xl font-semibold text-slate-800">Vollmacht-Generator</h1>
<p className="mb-4 text-sm text-slate-500">
Vollmacht gemäß § 13 SGB X, damit Sie die versicherte Person gegenüber der Krankenkasse/
Pflegekasse vertreten können – wird von beiden Seiten unterschrieben.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Collapsible
title="Vollmachtdaten"
open={formOffen}
onOpenChange={setFormOffen}
headingClassName="text-slate-700"
>
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">
Patient (Vollmachtgeber/in)
</label>
<select
value={patientId ?? ""}
onChange={(e) => patientWaehlen(Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">– wählen –</option>
{patienten?.map((p: Patient) => (
<option key={p.id} value={p.id}>
{p.vorname} {p.nachname}
</option>
))}
</select>
</div>
<KrankenkasseAutocomplete
value={krankenkasse}
onChange={setKrankenkasse}
label="Krankenkasse / Pflegekasse"
/>
<input
placeholder="Ausstellungsort"
value={ort}
onChange={(e) => setOrt(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs font-medium text-slate-500">Bevollmächtigte/r (Sie)</p>
<input
placeholder="Name"
value={bevollmaechtigterName}
onChange={(e) => setBevollmaechtigterName(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<input
placeholder="Straße und Hausnummer"
value={bevollmaechtigterStrasse}
onChange={(e) => setBevollmaechtigterStrasse(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<input
placeholder="PLZ Ort"
value={bevollmaechtigterPlzOrt}
onChange={(e) => setBevollmaechtigterPlzOrt(e.target.value)}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<button
onClick={erstellen}
disabled={vollmachtErstellen.isPending || !patientId || !bevollmaechtigterName.trim()}
className="mt-4 w-full rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Vollmacht erstellen
</button>
{vollmachtErstellen.isError && (
<p className="mt-2 text-sm text-red-600">{(vollmachtErstellen.error as Error).message}</p>
)}
</Collapsible>
<div
className={`rounded-2xl border p-4 ${
vollmachtErstellen.data ? getAccent("emerald").card : "border-slate-200 bg-white"
}`}
>
<p
className={`mb-2 text-sm font-semibold ${
vollmachtErstellen.data ? getAccent("emerald").heading : "text-slate-700"
}`}
>
Ergebnis
</p>
{vollmachtErstellen.data ? (
<div>
<p className="mb-3 text-sm font-medium text-emerald-700">Vollmacht erstellt ✓</p>
<p className="mb-3 text-xs text-slate-500">
Bitte ausdrucken und von beiden Seiten unterschreiben lassen. Ab jetzt unterschreiben
nachfolgend erstellte Widersprüche, Musterbriefe und Ummeldungen für diesen Patienten
automatisch „in Vollmacht" durch Sie.
</p>
<div className="flex gap-2">
<a
href={pdfUrl(patientId!, vollmachtErstellen.data.id)}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white"
>
PDF öffnen
</a>
<button
type="button"
onClick={() => setFormOffen(true)}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-600"
>
Formular bearbeiten
</button>
</div>
<pre className="mt-4 max-h-96 overflow-y-auto whitespace-pre-wrap rounded-lg bg-white/70 p-3 font-mono text-[11px] text-slate-700">
{vollmachtErstellen.data.brief_text}
</pre>
</div>
) : (
<p className="text-sm text-slate-500">Noch keine Vollmacht erstellt.</p>
)}
</div>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { usePatienten, type Patient } from "@/lib/patienten";
import { useWiderspruchGruende, useAntragMutationen, pdfUrl } from "@/lib/briefe";
import { DateWheelPicker } from "@/components/DateWheelPicker";
import { Collapsible } from "@/components/Collapsible";
import { AntragCheckliste } from "@/components/AntragCheckliste";
import { KrankenkasseAutocomplete } from "@/components/KrankenkasseAutocomplete";
import { accentForKey, getAccent } from "@/lib/theme";
@@ -120,6 +121,8 @@ export default function WiderspruchPage() {
</select>
</div>
{patientId && <AntragCheckliste patientId={patientId} typ="widerspruch" />}
<KrankenkasseAutocomplete value={krankenkasse} onChange={setKrankenkasse} />
<div>
@@ -5,7 +5,13 @@ import { usePublicEinstellungen } from "@/lib/einstellungen";
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
const { data } = usePublicEinstellungen();
const firmenname = data?.firmenname || "Pflegepilot";
// "firmenname" ist die rechtliche Betreiber-/Firmenbezeichnung (fuer
// Impressum und Copyright-Zeile), NICHT der Produktname - beide koennen
// bei einem Einzelunternehmer auseinanderfallen (hier: "Daniel Barac
// Onlinedienste" als Firma, "MeinPflegeFuchs" als Produkt). Der
// Markenname im Header ist deshalb bewusst fest verdrahtet, nicht an
// firmenname gekoppelt - analog zum Sidebar-Header in (app)/layout.tsx.
const firmenname = data?.firmenname || "MeinPflegeFuchs";
const jahr = new Date().getFullYear();
return (
@@ -13,7 +19,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<header className="border-b border-slate-200 bg-white/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Link href="/" className="text-lg font-semibold text-slate-800">
{firmenname}
MeinPflegeFuchs
</Link>
<Link
href="/dashboard"
@@ -1,56 +1,154 @@
"use client";
import type { Metadata } from "next";
import Link from "next/link";
import {
IconClipboardCheck,
IconBook2,
IconScale,
IconMail,
IconSignature,
IconTruck,
IconMapPin,
IconCamera,
IconChecklist,
IconNotebook,
IconFirstAidKit,
IconShieldCheck,
IconLock,
IconFileExport,
} from "@tabler/icons-react";
import { usePublicEinstellungen } from "@/lib/einstellungen";
import type { PublicEinstellungen } from "@/lib/einstellungen";
import { getAccent, type AccentName } from "@/lib/theme";
const VORTEILE: { icon: typeof IconBook2; titel: string; text: string; accent: AccentName }[] = [
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://meinpflegefuchs.de";
const STANDARD_SEO_TITEL = "MeinPflegeFuchs – Pflege organisieren statt Behörden hinterherlaufen";
const STANDARD_SEO_BESCHREIBUNG =
"Pflegegrad einschätzen, Anträge & Widersprüche erstellen, Pflegedienste in der Nähe finden, Dokumente per Handy scannen und den Pflegealltag organisieren – alles in einer App für pflegende Angehörige.";
const STANDARD_HERO_TITEL = "MeinPflegeFuchs nimmt dir die Behörden-Bürokratie ab";
const STANDARD_HERO_TEXT =
"Vom ersten Antrag bis zum Alltag mit Pflege: Pflegegrad einschätzen, Anträge und Widersprüche erstellen, Pflegedienste in der Nähe finden, Dokumente per Handy erfassen und nichts mehr vergessen – für beliebig viele Angehörige gleichzeitig.";
// Serverseitig geladen (kein Client-Hook) - sowohl fuer generateMetadata()
// als auch fuer die Seite selbst; Next.js dedupliziert identische fetch()-
// Aufrufe innerhalb desselben Requests automatisch, ein zweiter Aufruf
// unten kostet also keinen zusaetzlichen Roundtrip. Serverseitiges Laden
// statt eines Client-Query-Hooks ist hier bewusst, damit Suchmaschinen den
// tatsaechlichen Seiteninhalt (Hero-Text, Titel/Beschreibung) direkt im
// initialen HTML sehen, nicht erst nach einem Client-seitigen Nachladen.
//
// Bewusst NICHT NEXT_PUBLIC_API_URL verwendet: dieser Code laeuft im
// Next.js-Server-Prozess selbst (innerhalb des "web"-Docker-Containers),
// nicht im Browser. Auf der NAS ist NEXT_PUBLIC_API_URL sogar bewusst leer
// (der Reverse-Proxy leitet /api/* browserseitig relativ zur selben Domain
// weiter) - ein serverseitiger fetch() auf einen relativen Pfad haette
// dort keine Basis-URL und wuerde fehlschlagen. Und selbst ein gesetzter
// Wert waere hier falsch: "localhost:8100" ist nur der HOST-seitig
// gemappte Port, aus einem anderen Container (hier: web) heraus nicht
// erreichbar. Der korrekte Weg fuer Container-zu-Container-Kommunikation
// ist der interne Docker-Compose-Servicename (siehe API_INTERNAL_URL in
// docker-compose.yml) - "api:5050" gilt gleichermassen fuer lokale
// Entwicklung wie fuer die NAS, da beide dieselbe docker-compose.yml mit
// denselben Servicenamen nutzen.
async function ladeOeffentlicheEinstellungen(): Promise<Partial<PublicEinstellungen>> {
const apiUrl = process.env.API_INTERNAL_URL || "http://api:5050";
try {
// "no-store" statt einer Revalidate-Frist: der Betreiber soll eine in
// den Backend-Einstellungen gespeicherte Aenderung (Hero-Text, SEO-
// Meta) sofort auf der Landingpage sehen, nicht erst nach Ablauf eines
// Cache-Fensters - der Traffic auf dieser einen Seite rechtfertigt den
// zusaetzlichen API-Aufruf pro Seitenaufruf ohne Weiteres.
const res = await fetch(`${apiUrl}/api/einstellungen/public`, { cache: "no-store" });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const s = await ladeOeffentlicheEinstellungen();
const titel = s.landing_seo_title || STANDARD_SEO_TITEL;
const beschreibung = s.landing_seo_description || STANDARD_SEO_BESCHREIBUNG;
return {
title: titel,
description: beschreibung,
alternates: { canonical: SITE_URL },
openGraph: {
title: titel,
description: beschreibung,
url: SITE_URL,
siteName: "MeinPflegeFuchs",
locale: "de_DE",
type: "website",
},
twitter: {
card: "summary",
title: titel,
description: beschreibung,
},
};
}
const FEATURES: { icon: typeof IconBook2; titel: string; text: string; accent: AccentName }[] = [
{
icon: IconClipboardCheck,
titel: "Pflegegrad-Einschätzung",
text: "Regelbasierte Ersteinschätzung in wenigen Minuten – ein verlässlicher erster Anhaltspunkt vor der Begutachtung.",
text: "Ein kurzes, verständliches Interview liefert einen ersten Anhaltspunkt zum Pflegegrad – noch bevor der Medizinische Dienst zur Begutachtung kommt.",
accent: "emerald",
},
{
icon: IconBook2,
titel: "Leistungskatalog & Rechner",
text: "Über 80 Pflegeleistungen durchsuchen und direkt sehen, welche für die individuelle Situation infrage kommen.",
titel: "Leistungskatalog & Geld-Rechner",
text: "Über 80 Pflegeleistungen durchsuchen und auf einen Blick sehen, wie viel Pflegegeld, Pflegesachleistung, Entlastungsbetrag & Co. monatlich und jährlich möglich sind.",
accent: "sky",
},
{
icon: IconMail,
titel: "Anträge & Musterbriefe",
text: "Erstantrag, Höherstufung, Eingliederungshilfe, Grundsicherung, Blindenhilfe – automatisch mit den hinterlegten Daten ausgefüllt und als PDF sofort einsatzbereit.",
accent: "amber",
},
{
icon: IconScale,
titel: "Widerspruch-Generator",
text: "Bei abgelehntem oder zu niedrigem Pflegegrad automatisch ein rechtlich fundiertes Widerspruchsschreiben erstellen.",
text: "Bei abgelehntem oder zu niedrig eingestuftem Pflegegrad in wenigen Minuten ein rechtlich fundiertes Widerspruchsschreiben erstellen.",
accent: "violet",
},
{
icon: IconMail,
titel: "Musterbrief-Generator",
text: "Anträge für Pflegegrad, Eingliederungshilfe, Grundsicherung und Blindenhilfe direkt ausfüllen und versenden.",
accent: "amber",
icon: IconSignature,
titel: "Vollmacht & Patientenverfügung",
text: "Vollmacht nach § 13 SGB X sowie eine Patientenverfügung nach den offiziellen Textbausteinen des Bundesjustizministeriums – fertig zum gemeinsamen Unterschreiben.",
accent: "rose",
},
{
icon: IconChecklist,
titel: "Aufgaben & Kalender",
text: "Fristen, Termine und To-dos für jeden Angehörigen im Blick – nichts geht mehr unter.",
icon: IconTruck,
titel: "Umzug leicht gemacht",
text: "Bei einer Adressänderung informiert ein Klick automatisch Kranken- und Rentenversicherung – inklusive fertigem Anschreiben an beide Stellen.",
accent: "teal",
},
{
icon: IconNotebook,
titel: "Pflegetagebuch & Dokumente",
text: "Pflegeaufwand dokumentieren und wichtige Unterlagen sicher an einem Ort ablegen.",
accent: "rose",
icon: IconMapPin,
titel: "Pflegedienste & Anbieter in der Nähe",
text: "Ambulante Pflegedienste, Sanitätshäuser, Tagespflege, betreutes Wohnen, Therapie- und Reha-Einrichtungen in der Umgebung finden, als Favorit merken und automatisch informiert werden, wenn sich Kontaktdaten ändern.",
accent: "emerald",
},
{
icon: IconCamera,
titel: "Dokumente per Handykamera erfassen",
text: "Bescheide und Unterlagen direkt mit dem Smartphone einscannen, die Krankenkassenkarte abfotografieren und Formulardaten automatisch übernehmen lassen.",
accent: "sky",
},
{
icon: IconChecklist,
titel: "Alltag im Griff",
text: "Aufgaben mit Fristen, Kalender-Export für Apple & Google, Pflegetagebuch mit täglicher Erinnerung sowie ein Budget-Tracker für den Entlastungsbetrag.",
accent: "amber",
},
{
icon: IconFirstAidKit,
titel: "Checklisten für den Ernstfall",
text: "Schritt-für-Schritt-Anleitungen für Krisenmomente – plötzliche Krankenhauseinweisung, Sturz, Ausfall der Pflegeperson und mehr.",
accent: "violet",
},
];
@@ -72,26 +170,44 @@ const DSGVO_PUNKTE = [
},
];
export default function LandingPage() {
const { data } = usePublicEinstellungen();
const preisBasis = data?.abo_preis_basis ?? "14.99";
const preisZusatz = data?.abo_preis_zusatzpatient ?? "4.99";
const waehrung = data?.abo_waehrung === "EUR" ? "€" : (data?.abo_waehrung ?? "€");
export default async function LandingPage() {
const s = await ladeOeffentlicheEinstellungen();
const heroTitel = s.landing_hero_titel || STANDARD_HERO_TITEL;
const heroText = s.landing_hero_text || STANDARD_HERO_TEXT;
const preisBasis = s.abo_preis_basis ?? "14.99";
const preisZusatz = s.abo_preis_zusatzpatient ?? "4.99";
const waehrung = s.abo_waehrung === "EUR" ? "€" : (s.abo_waehrung ?? "€");
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "MeinPflegeFuchs",
applicationCategory: "HealthApplication",
operatingSystem: "Web",
description: s.landing_seo_description || STANDARD_SEO_BESCHREIBUNG,
url: SITE_URL,
offers: {
"@type": "Offer",
price: preisBasis,
priceCurrency: "EUR",
description: "Basispreis für einen verwalteten Angehörigen, monatlich kündbar",
},
};
return (
<div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
{/* Hero */}
<section className="mx-auto max-w-4xl px-6 pt-20 pb-16 text-center">
<p className="mb-3 text-sm font-semibold tracking-wide text-emerald-700 uppercase">
Pflegeplanung für Angehörige
</p>
<h1 className="mb-5 text-4xl font-semibold text-slate-800 sm:text-5xl">
Pflegepilot nimmt dir die Behörden-Bürokratie ab
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-slate-600">
Pflegegrad einschätzen, Anträge stellen, Widersprüche formulieren und Fristen im Blick
behalten – alles an einem Ort, für beliebig viele Angehörige gleichzeitig.
</p>
<h1 className="mb-5 text-4xl font-semibold text-slate-800 sm:text-5xl">{heroTitel}</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-slate-600">{heroText}</p>
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link
href="/dashboard"
@@ -105,20 +221,23 @@ export default function LandingPage() {
</div>
</section>
{/* Vorteile */}
{/* Features */}
<section className="mx-auto max-w-6xl px-6 py-12">
<h2 className="mb-8 text-center text-2xl font-semibold text-slate-800">
Was Pflegepilot für dich übernimmt
<h2 className="mb-2 text-center text-2xl font-semibold text-slate-800">
Alles, was Pflege im Alltag einfacher macht
</h2>
<p className="mx-auto mb-8 max-w-2xl text-center text-slate-600">
Eine App statt zehn Behördenordner – für den monatlichen Preis eines Streaming-Abos.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{VORTEILE.map((v) => {
const accent = getAccent(v.accent);
const Icon = v.icon;
{FEATURES.map((f) => {
const accent = getAccent(f.accent);
const Icon = f.icon;
return (
<div key={v.titel} className={`rounded-2xl border p-5 ${accent.card}`}>
<Icon className={`mb-3 h-7 w-7 ${accent.heading}`} stroke={1.75} />
<h3 className={`mb-1.5 font-semibold ${accent.heading}`}>{v.titel}</h3>
<p className="text-sm text-slate-600">{v.text}</p>
<div key={f.titel} className={`rounded-2xl border p-5 ${accent.card}`}>
<Icon className={`mb-3 h-7 w-7 ${accent.heading}`} stroke={1.75} aria-hidden="true" />
<h3 className={`mb-1.5 font-semibold ${accent.heading}`}>{f.titel}</h3>
<p className="text-sm text-slate-600">{f.text}</p>
</div>
);
})}
@@ -128,9 +247,7 @@ export default function LandingPage() {
{/* Preis */}
<section className="mx-auto max-w-2xl px-6 py-12">
<div className="rounded-2xl border border-emerald-200 bg-gradient-to-br from-emerald-50 to-teal-50 p-8 text-center">
<h2 className="mb-2 text-xl font-semibold text-emerald-800">
Alle Funktionen inklusive
</h2>
<h2 className="mb-2 text-xl font-semibold text-emerald-800">Alle Funktionen inklusive</h2>
<p className="mb-1 text-4xl font-semibold text-slate-800">
{preisBasis.replace(".", ",")} {waehrung}
<span className="text-lg font-normal text-slate-500"> / Monat</span>
@@ -149,13 +266,11 @@ export default function LandingPage() {
{/* DSGVO / Sicherheit */}
<section className="mx-auto max-w-6xl px-6 py-12">
<h2 className="mb-8 text-center text-2xl font-semibold text-slate-800">
Deine Daten sind sicher
</h2>
<h2 className="mb-8 text-center text-2xl font-semibold text-slate-800">Deine Daten sind sicher</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{DSGVO_PUNKTE.map((p) => (
<div key={p.titel} className="rounded-2xl border border-slate-200 bg-white p-5 text-center">
<p.icon className="mx-auto mb-3 h-7 w-7 text-slate-600" stroke={1.75} />
<p.icon className="mx-auto mb-3 h-7 w-7 text-slate-600" stroke={1.75} aria-hidden="true" />
<h3 className="mb-1.5 font-semibold text-slate-800">{p.titel}</h3>
<p className="text-sm text-slate-600">{p.text}</p>
</div>
@@ -166,9 +281,7 @@ export default function LandingPage() {
{/* Abschluss-CTA */}
<section className="mx-auto max-w-4xl px-6 py-16 text-center">
<h2 className="mb-3 text-2xl font-semibold text-slate-800">Bereit loszulegen?</h2>
<p className="mb-6 text-slate-600">
In wenigen Minuten angemeldet und den ersten Angehörigen erfasst.
</p>
<p className="mb-6 text-slate-600">In wenigen Minuten angemeldet und den ersten Angehörigen erfasst.</p>
<Link
href="/dashboard"
className="inline-block rounded-lg bg-slate-800 px-6 py-3 text-base font-medium text-white hover:bg-slate-700"
@@ -0,0 +1,105 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
import { ApiError } from "@/lib/api";
function PasswortZuruecksetzenInner() {
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const { passwortZuruecksetzen } = useAuth();
const [neuesPasswort, setNeuesPasswort] = useState("");
const [wiederholung, setWiederholung] = useState("");
const [validierungsfehler, setValidierungsfehler] = useState<string | null>(null);
if (!token) {
return (
<p className="text-sm text-red-600">
Dieser Link ist unvollständig. Bitte den Link direkt aus der E-Mail öffnen.
</p>
);
}
if (passwortZuruecksetzen.isSuccess) {
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-emerald-700">
Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt anmelden.
</p>
<Link
href="/dashboard"
className="rounded-lg bg-slate-800 px-4 py-2 text-center text-sm font-medium text-white"
>
Zur Anmeldung
</Link>
</div>
);
}
const apiFehler =
passwortZuruecksetzen.isError ? (passwortZuruecksetzen.error as ApiError).message : null;
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
if (neuesPasswort.length < 6) {
setValidierungsfehler("Passwort muss mindestens 6 Zeichen lang sein");
return;
}
if (neuesPasswort !== wiederholung) {
setValidierungsfehler("Die beiden Passwörter stimmen nicht überein");
return;
}
setValidierungsfehler(null);
passwortZuruecksetzen.mutate({ token, neues_passwort: neuesPasswort });
}}
>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Neues Passwort</label>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
type="password"
value={neuesPasswort}
onChange={(e) => setNeuesPasswort(e.target.value)}
autoComplete="new-password"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-500">Passwort wiederholen</label>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
type="password"
value={wiederholung}
onChange={(e) => setWiederholung(e.target.value)}
autoComplete="new-password"
/>
</div>
{(validierungsfehler || apiFehler) && (
<p className="text-sm text-red-600">{validierungsfehler ?? apiFehler}</p>
)}
<button
type="submit"
disabled={passwortZuruecksetzen.isPending}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Passwort speichern
</button>
</form>
);
}
export default function PasswortZuruecksetzenPage() {
return (
<div className="mx-auto max-w-sm p-6 py-16">
<h1 className="mb-4 text-2xl font-semibold text-slate-800">Passwort zurücksetzen</h1>
<Suspense fallback={<p className="text-slate-500">Lädt…</p>}>
<PasswortZuruecksetzenInner />
</Suspense>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Pflegepilot",
title: "MeinPflegeFuchs",
description: "Pflegeplanung für pflegende Angehörige",
};
+38
View File
@@ -0,0 +1,38 @@
import type { MetadataRoute } from "next";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://meinpflegefuchs.de";
// Explizite Allow-Liste (oeffentliche Marketing-Seiten) + explizite
// Disallow-Liste (alles unter der (app)-Routengruppe, ohnehin per Login-
// Overlay geschuetzt) statt eines einzigen Disallow:"/" mit Allow-
// Ausnahmen - bei neuen (app)-Routen lieber explizit nachtragen als sich
// auf eine Wildcard-Umkehrlogik zu verlassen, die leicht uebersehen wird.
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: ["/", "/impressum", "/datenschutz", "/agb"],
disallow: [
"/dashboard",
"/admin",
"/patienten",
"/aufgaben",
"/changelog",
"/einstellungen",
"/guides",
"/kalender",
"/katalog",
"/meinabo",
"/musterbrief",
"/patientenverfuegung",
"/rechner",
"/umkreissuche",
"/ummeldung",
"/vollmacht",
"/widerspruch",
"/passwort-zuruecksetzen",
],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
@@ -0,0 +1,19 @@
import type { MetadataRoute } from "next";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://meinpflegefuchs.de";
export default function sitemap(): MetadataRoute.Sitemap {
const seiten: { pfad: string; prioritaet: number; frequenz: "weekly" | "yearly" }[] = [
{ pfad: "", prioritaet: 1, frequenz: "weekly" },
{ pfad: "/impressum", prioritaet: 0.3, frequenz: "yearly" },
{ pfad: "/datenschutz", prioritaet: 0.3, frequenz: "yearly" },
{ pfad: "/agb", prioritaet: 0.3, frequenz: "yearly" },
];
return seiten.map(({ pfad, prioritaet, frequenz }) => ({
url: `${SITE_URL}${pfad}`,
lastModified: new Date(),
changeFrequency: frequenz,
priority: prioritaet,
}));
}
@@ -0,0 +1,92 @@
"use client";
import { useRef, useState } from "react";
import { useAntragCheckliste } from "@/lib/briefe";
import { useDokumente, useDokumentMutationen } from "@/lib/dokumente";
export function AntragCheckliste({ patientId, typ }: { patientId: number; typ: string }) {
const { data: eintraege } = useAntragCheckliste(typ);
const { data: dokumente } = useDokumente(patientId);
const { hochladen } = useDokumentMutationen(patientId);
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
const [hochladendTyp, setHochladendTyp] = useState<string | null>(null);
if (!eintraege || eintraege.length === 0) return null;
const vorhandeneTypen = new Set((dokumente ?? []).map((d) => d.typ));
const hochladenFuer = (dokTyp: string, datei: File) => {
setHochladendTyp(dokTyp);
hochladen.mutate(
{ datei, typ: dokTyp, bezeichnung: datei.name },
{ onSettled: () => setHochladendTyp(null) }
);
};
return (
<div className="mt-2 rounded-lg border border-slate-200 bg-white p-3">
<p className="mb-2 text-xs font-semibold text-slate-600">
Checkliste: für diesen Antrag üblicherweise benötigte Unterlagen
</p>
<ul className="flex flex-col gap-1.5">
{eintraege.map((e) => {
const vorhanden = vorhandeneTypen.has(e.typ);
const laedt = hochladendTyp === e.typ;
if (vorhanden) {
return (
<li
key={e.typ}
className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-1.5 text-xs text-emerald-800"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-[10px] font-bold text-white">
✓
</span>
<span>{e.label}</span>
</li>
);
}
return (
<li key={e.typ}>
<button
type="button"
disabled={laedt}
onClick={() => fileInputs.current[e.typ]?.click()}
className="flex w-full items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-left text-xs text-red-800 transition hover:bg-red-100 disabled:opacity-60"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
✕
</span>
<span className="flex-1">{e.label} – fehlt noch</span>
<span className="shrink-0 font-medium underline">
{laedt ? "Wird hochgeladen…" : "Hochladen"}
</span>
</button>
<input
ref={(el) => {
fileInputs.current[e.typ] = el;
}}
type="file"
className="hidden"
onChange={(ev) => {
const datei = ev.target.files?.[0];
ev.target.value = "";
if (!datei) return;
hochladenFuer(e.typ, datei);
}}
/>
</li>
);
})}
</ul>
{hochladen.isError && (
<p className="mt-2 text-xs text-red-600">{(hochladen.error as Error).message}</p>
)}
<p className="mt-2 text-[11px] text-slate-400">
Unverbindliche Orientierung – die tatsächlich benötigten Unterlagen können je nach Kasse/Amt
und Einzelfall abweichen. Kein Hinderungsgrund für die Antragstellung.
</p>
</div>
);
}
@@ -1,29 +1,37 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
import { ApiError } from "@/lib/api";
import { usePlzSync } from "@/lib/plz";
export function AuthOverlay() {
const [mode, setMode] = useState<"login" | "register">("login");
const { login, registrieren } = useAuth();
const [mode, setMode] = useState<"login" | "register" | "forgot">("login");
const { login, registrieren, passwortVergessen } = useAuth();
const titel = {
login: "Bitte melde dich an.",
register: "Neues Konto anlegen.",
forgot: "Passwort zurücksetzen.",
}[mode];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-8 shadow-xl">
<h1 className="mb-1 text-xl font-semibold text-slate-800">Pflegepilot</h1>
<p className="mb-6 text-sm text-slate-500">
{mode === "login" ? "Bitte melde dich an." : "Neues Konto anlegen."}
</p>
<h1 className="mb-1 text-xl font-semibold text-slate-800">MeinPflegeFuchs</h1>
<p className="mb-6 text-sm text-slate-500">{titel}</p>
{mode === "login" ? (
{mode === "login" && (
<LoginForm
onSwitch={() => setMode("register")}
onForgot={() => setMode("forgot")}
onLogin={(payload) => login.mutate(payload)}
error={login.isError ? (login.error as ApiError).message : null}
pending={login.isPending}
/>
) : (
)}
{mode === "register" && (
<RegisterForm
onSwitch={() => setMode("login")}
onRegister={(payload) => registrieren.mutate(payload)}
@@ -31,6 +39,14 @@ export function AuthOverlay() {
pending={registrieren.isPending}
/>
)}
{mode === "forgot" && (
<ForgotPasswordForm
onSwitch={() => setMode("login")}
onSubmit={(email) => passwortVergessen.mutate({ email })}
success={passwortVergessen.isSuccess}
pending={passwortVergessen.isPending}
/>
)}
</div>
</div>
);
@@ -38,11 +54,13 @@ export function AuthOverlay() {
function LoginForm({
onSwitch,
onForgot,
onLogin,
error,
pending,
}: {
onSwitch: () => void;
onForgot: () => void;
onLogin: (payload: { benutzername: string; passwort: string }) => void;
error: string | null;
pending: boolean;
@@ -89,12 +107,88 @@ function LoginForm({
>
Demo ausprobieren
</button>
<button
type="button"
onClick={onForgot}
className="mt-1 text-sm text-slate-500 underline underline-offset-2"
>
Passwort vergessen?
</button>
<button
type="button"
onClick={onSwitch}
className="text-sm text-slate-500 underline underline-offset-2"
>
Noch kein Konto? Jetzt registrieren
</button>
</form>
);
}
function ForgotPasswordForm({
onSwitch,
onSubmit,
success,
pending,
}: {
onSwitch: () => void;
onSubmit: (email: string) => void;
success: boolean;
pending: boolean;
}) {
const [email, setEmail] = useState("");
if (success) {
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-slate-600">
Falls zu dieser E-Mail-Adresse ein Konto existiert, haben wir soeben eine E-Mail mit
einem Link zum Zurücksetzen des Passworts geschickt. Der Link ist eine Stunde gültig.
</p>
<button
type="button"
onClick={onSwitch}
className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-medium text-white"
>
Zurück zur Anmeldung
</button>
</div>
);
}
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
onSubmit(email);
}}
>
<p className="text-sm text-slate-600">
Bitte die bei der Registrierung hinterlegte E-Mail-Adresse eingeben. Wir senden dir dann
einen Link zum Zurücksetzen deines Passworts.
</p>
<input
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="E-Mail-Adresse"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
<button
type="submit"
disabled={pending || !email.trim()}
className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Link zum Zurücksetzen senden
</button>
<button
type="button"
onClick={onSwitch}
className="mt-2 text-sm text-slate-500 underline underline-offset-2"
>
Noch kein Konto? Jetzt registrieren
Zurück zur Anmeldung
</button>
</form>
);
@@ -111,10 +205,13 @@ function RegisterForm({
benutzername: string;
passwort: string;
name: string;
email: string;
rg_name: string;
rg_strasse: string;
rg_plz: string;
rg_ort: string;
zustimmung_sofortbeginn: boolean;
zustimmung_widerrufsverzicht: boolean;
}) => void;
error: string | null;
pending: boolean;
@@ -123,21 +220,33 @@ function RegisterForm({
benutzername: "",
passwort: "",
name: "",
email: "",
rg_name: "",
rg_strasse: "",
rg_plz: "",
rg_ort: "",
});
const [zustimmungSofortbeginn, setZustimmungSofortbeginn] = useState(false);
const [zustimmungWiderrufsverzicht, setZustimmungWiderrufsverzicht] = useState(false);
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }));
usePlzSync(form.rg_plz, (v) => setForm((f) => ({ ...f, rg_ort: v })));
const einwilligungenVollstaendig = zustimmungSofortbeginn && zustimmungWiderrufsverzicht;
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
onRegister(form);
if (!einwilligungenVollstaendig) return;
onRegister({
...form,
zustimmung_sofortbeginn: zustimmungSofortbeginn,
zustimmung_widerrufsverzicht: zustimmungWiderrufsverzicht,
});
}}
>
<input
@@ -161,6 +270,15 @@ function RegisterForm({
onChange={set("passwort")}
autoComplete="new-password"
/>
<input
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="E-Mail-Adresse"
type="email"
value={form.email}
onChange={set("email")}
autoComplete="email"
required
/>
<p className="mt-1 text-xs font-medium text-slate-500">Rechnungsanschrift</p>
<input
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
@@ -180,6 +298,7 @@ function RegisterForm({
placeholder="PLZ"
value={form.rg_plz}
onChange={set("rg_plz")}
maxLength={5}
/>
<input
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm"
@@ -188,10 +307,40 @@ function RegisterForm({
onChange={set("rg_ort")}
/>
</div>
<label className="flex items-start gap-2 text-xs text-slate-600">
<input
type="checkbox"
required
checked={zustimmungSofortbeginn}
onChange={(e) => setZustimmungSofortbeginn(e.target.checked)}
className="mt-0.5"
/>
<span>
Ich stimme ausdrücklich zu, dass Sie mit der Ausführung des Vertrags vor Ablauf der
Widerrufsfrist beginnen.
</span>
</label>
<label className="flex items-start gap-2 text-xs text-slate-600">
<input
type="checkbox"
required
checked={zustimmungWiderrufsverzicht}
onChange={(e) => setZustimmungWiderrufsverzicht(e.target.checked)}
className="mt-0.5"
/>
<span>
Ich bestätige meine Kenntnis darüber, dass ich durch meine Zustimmung mit Beginn der
Ausführung mein Widerrufsrecht verliere. Details siehe{" "}
<Link href="/agb" target="_blank" rel="noreferrer" className="underline">
Nutzungsbedingungen
</Link>
.
</span>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={pending}
disabled={pending || !einwilligungenVollstaendig}
className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
>
Registrieren
@@ -16,16 +16,19 @@ export function BentoTile({
span = 1,
accent,
className = "",
id,
}: {
children: React.ReactNode;
span?: 1 | 2 | 4;
accent?: keyof typeof gradients;
className?: string;
id?: string;
}) {
const spanClass = span === 4 ? "lg:col-span-4" : span === 2 ? "lg:col-span-2" : "";
const accentClass = accent ? `bg-gradient-to-br ${gradients[accent]}` : "bg-white border-slate-200";
return (
<div
id={id}
className={`rounded-2xl border p-5 shadow-sm ${accentClass} ${spanClass} ${className}`}
>
{children}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import {
useLeistungAusgaben,
useLeistungAusgabeMutationen,
useLeistungMutationen,
type Leistung,
} from "@/lib/leistungen";
export function BudgetTracker({ leistung }: { leistung: Leistung }) {
const [offen, setOffen] = useState(false);
const [budgetEingabe, setBudgetEingabe] = useState(leistung.budget_jahr ?? "");
const [neueAusgabe, setNeueAusgabe] = useState({
betrag: "",
datum: new Date().toISOString().slice(0, 10),
beschreibung: "",
});
const { data: ausgaben } = useLeistungAusgaben(leistung.id);
const { anlegen: ausgabeAnlegen, loeschen: ausgabeLoeschen } = useLeistungAusgabeMutationen(leistung.id);
const { budgetSetzen } = useLeistungMutationen(leistung.patient_id);
if (!offen) {
return (
<button
type="button"
onClick={() => setOffen(true)}
className="self-start text-xs text-slate-500 underline"
>
Budget &amp; Ausgaben
</button>
);
}
const budget = leistung.budget_jahr ? Number(leistung.budget_jahr) : null;
const aktuellesJahr = new Date().getFullYear();
const verbraucht = (ausgaben ?? [])
.filter((a) => new Date(a.datum).getFullYear() === aktuellesJahr)
.reduce((summe, a) => summe + Number(a.betrag), 0);
const rest = budget !== null ? budget - verbraucht : null;
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium text-slate-600">Budget &amp; Ausgaben {aktuellesJahr}</span>
<button type="button" onClick={() => setOffen(false)} className="text-slate-400 underline">
Zuklappen
</button>
</div>
<div className="mb-2 flex items-center gap-2">
<label className="text-slate-500">Jahresbudget (€)</label>
<input
type="number"
step="0.01"
value={budgetEingabe}
onChange={(e) => setBudgetEingabe(e.target.value)}
onBlur={() =>
budgetSetzen.mutate({
lid: leistung.id,
budget_jahr: budgetEingabe === "" ? null : Number(budgetEingabe),
})
}
className="w-24 rounded border border-slate-300 px-2 py-1"
/>
</div>
{budget !== null && rest !== null && (
<div
className={`mb-2 rounded-lg p-2 ${
rest < 0 ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-800"
}`}
>
Noch {rest.toFixed(2)} € von {budget.toFixed(2)} € übrig ({aktuellesJahr}).
{rest > 0 && (
<> Wird der Rest bis zum 30.06.{aktuellesJahr + 1} nicht genutzt, verfällt er.</>
)}
</div>
)}
<ul className="mb-2 flex flex-col gap-1">
{ausgaben?.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2">
<span>
{a.datum} – {Number(a.betrag).toFixed(2)} €{a.beschreibung ? ` (${a.beschreibung})` : ""}
</span>
<button
type="button"
onClick={() => ausgabeLoeschen.mutate(a.id)}
className="shrink-0 text-red-600 underline"
>
Löschen
</button>
</li>
))}
{(!ausgaben || ausgaben.length === 0) && (
<li className="text-slate-400">Noch keine Ausgaben erfasst.</li>
)}
</ul>
<div className="flex flex-wrap items-center gap-1.5">
<input
type="date"
value={neueAusgabe.datum}
onChange={(e) => setNeueAusgabe((f) => ({ ...f, datum: e.target.value }))}
className="rounded border border-slate-300 px-2 py-1"
/>
<input
type="number"
step="0.01"
placeholder="Betrag €"
value={neueAusgabe.betrag}
onChange={(e) => setNeueAusgabe((f) => ({ ...f, betrag: e.target.value }))}
className="w-20 rounded border border-slate-300 px-2 py-1"
/>
<input
placeholder="Beschreibung (optional)"
value={neueAusgabe.beschreibung}
onChange={(e) => setNeueAusgabe((f) => ({ ...f, beschreibung: e.target.value }))}
className="flex-1 rounded border border-slate-300 px-2 py-1"
/>
<button
type="button"
disabled={!neueAusgabe.betrag || ausgabeAnlegen.isPending}
onClick={() =>
ausgabeAnlegen.mutate(
{
betrag: Number(neueAusgabe.betrag),
datum: neueAusgabe.datum,
beschreibung: neueAusgabe.beschreibung || undefined,
},
{ onSuccess: () => setNeueAusgabe((f) => ({ ...f, betrag: "", beschreibung: "" })) }
)
}
className="rounded bg-slate-800 px-2 py-1 text-white disabled:opacity-50"
>
+ Erfassen
</button>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconCamera, IconX } from "@tabler/icons-react";
/** Mehrseiten-Kamera-Erfassung fuer den Dokumenten-Scan (Betreiber-Feedback:
* Zielgruppe ist zu ca. 80% Tablet-/Handy-Nutzer/innen ohne eigenen
* Scanner). `capture="environment"` oeffnet auf dem Handy direkt die Kamera
* statt der allgemeinen Dateiauswahl; jeder Tastendruck auf "Seite
* aufnehmen" loest eine neue Kamera-Aufnahme aus (ein <input capture>
* liefert immer nur ein Foto pro Oeffnung), gesammelte Seiten werden erst
* beim Abschluss als eine Anfrage hochgeladen. Kantenerkennung/
* Perspektivkorrektur passiert serverseitig (siehe app/dokument_scan.py). */
export function DokumentScan({
onFertig,
hochladenLaeuft,
fehler,
}: {
onFertig: (dateien: File[]) => void;
hochladenLaeuft: boolean;
fehler?: string | null;
}) {
const [seiten, setSeiten] = useState<File[]>([]);
const [vorschauUrls, setVorschauUrls] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const urls = seiten.map((f) => URL.createObjectURL(f));
setVorschauUrls(urls);
return () => urls.forEach((u) => URL.revokeObjectURL(u));
}, [seiten]);
const seiteHinzufuegen = (e: React.ChangeEvent<HTMLInputElement>) => {
const datei = e.target.files?.[0];
if (datei) setSeiten((s) => [...s, datei]);
e.target.value = "";
};
const seiteEntfernen = (i: number) => setSeiten((s) => s.filter((_, idx) => idx !== i));
const abbrechen = () => setSeiten([]);
const fertig = () => {
onFertig(seiten);
setSeiten([]);
};
return (
<div className="flex flex-col gap-2">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
onChange={seiteHinzufuegen}
className="hidden"
/>
{seiten.length === 0 ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex items-center gap-1.5 rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50"
>
<IconCamera size={16} />
Scannen
</button>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-2">
<div className="mb-2 flex flex-wrap gap-2">
{vorschauUrls.map((url, i) => (
<div key={i} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`Seite ${i + 1}`} className="h-16 w-12 rounded border border-slate-300 object-cover" />
<button
type="button"
onClick={() => seiteEntfernen(i)}
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-slate-700 text-white"
aria-label={`Seite ${i + 1} entfernen`}
>
<IconX size={10} />
</button>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs text-slate-600"
>
+ weitere Seite
</button>
<button
type="button"
onClick={fertig}
disabled={hochladenLaeuft}
className="rounded-lg bg-slate-800 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-50"
>
{hochladenLaeuft
? "Wird verarbeitet…"
: `Fertig, hochladen (${seiten.length} Seite${seiten.length > 1 ? "n" : ""})`}
</button>
<button
type="button"
onClick={abbrechen}
disabled={hochladenLaeuft}
className="text-xs text-slate-500 underline"
>
Abbrechen
</button>
</div>
</div>
)}
{fehler && <p className="text-xs text-red-600">{fehler}</p>}
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconCamera, IconX } from "@tabler/icons-react";
import { useEgkScanMutation, type EgkScanErgebnis } from "@/lib/egk";
/** Kamera-Erfassung der elektronischen Gesundheitskarte (eGK) fuer Auto-Fill
* bei Patientenanlage/-bearbeitung (Tester-Feedback T8). Bis zu 2 Fotos
* (Vorder-/Rueckseite), Erkennung laeuft serverseitig per Tesseract-OCR
* (app/egk_ocr.py) - Ergebnis wird nur an den Aufrufer weitergereicht und
* dort in bestehende Formularfelder uebernommen, nichts wird hier
* gespeichert. Erkennung ist bewusst best-effort: alle Felder bleiben nach
* der Uebernahme normal editierbar. */
export function EgkScan({ onErgebnis }: { onErgebnis: (ergebnis: EgkScanErgebnis) => void }) {
const [seiten, setSeiten] = useState<File[]>([]);
const [vorschauUrls, setVorschauUrls] = useState<string[]>([]);
const [hinweis, setHinweis] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scan = useEgkScanMutation();
useEffect(() => {
const urls = seiten.map((f) => URL.createObjectURL(f));
setVorschauUrls(urls);
return () => urls.forEach((u) => URL.revokeObjectURL(u));
}, [seiten]);
const seiteHinzufuegen = (e: React.ChangeEvent<HTMLInputElement>) => {
const datei = e.target.files?.[0];
if (datei) setSeiten((s) => (s.length < 2 ? [...s, datei] : s));
e.target.value = "";
};
const seiteEntfernen = (i: number) => setSeiten((s) => s.filter((_, idx) => idx !== i));
const abbrechen = () => {
setSeiten([]);
setHinweis(null);
};
const erkennen = async () => {
setHinweis(null);
try {
const ergebnis = await scan.mutateAsync(seiten);
const nichtsErkannt = Object.values(ergebnis).every((v) => !v);
setHinweis(
nichtsErkannt
? "Es konnten keine Angaben erkannt werden – bitte manuell eintragen."
: "Erkannte Angaben wurden übernommen – bitte prüfen."
);
onErgebnis(ergebnis);
setSeiten([]);
} catch (e) {
setHinweis(e instanceof Error ? e.message : "Erkennung fehlgeschlagen");
}
};
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
onChange={seiteHinzufuegen}
className="hidden"
/>
<p className="mb-2 text-xs font-medium text-slate-600">
Krankenkassenkarte abfotografieren (Vorder- und ggf. Rückseite) – Felder werden
automatisch befüllt, bitte danach prüfen.
</p>
{seiten.length === 0 ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100"
>
<IconCamera size={16} />
Karte scannen
</button>
) : (
<div>
<div className="mb-2 flex flex-wrap gap-2">
{vorschauUrls.map((url, i) => (
<div key={i} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`Seite ${i + 1}`}
className="h-16 w-24 rounded border border-slate-300 object-cover"
/>
<button
type="button"
onClick={() => seiteEntfernen(i)}
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-slate-700 text-white"
aria-label={`Seite ${i + 1} entfernen`}
>
<IconX size={10} />
</button>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{seiten.length < 2 && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs text-slate-600"
>
+ Rückseite
</button>
)}
<button
type="button"
onClick={erkennen}
disabled={scan.isPending}
className="rounded-lg bg-slate-800 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-50"
>
{scan.isPending ? "Wird erkannt…" : "Erkennen"}
</button>
<button
type="button"
onClick={abbrechen}
disabled={scan.isPending}
className="text-xs text-slate-500 underline"
>
Abbrechen
</button>
</div>
</div>
)}
{hinweis && <p className="mt-2 text-xs text-slate-600">{hinweis}</p>}
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
export function InfoTooltip({ text }: { text: string }) {
const [offen, setOffen] = useState(false);
return (
<span className="relative inline-flex items-center align-middle">
<button
type="button"
onClick={() => setOffen((v) => !v)}
onMouseEnter={() => setOffen(true)}
onMouseLeave={() => setOffen(false)}
onBlur={() => setOffen(false)}
className="ml-1 inline-flex shrink-0 text-slate-400 hover:text-slate-600"
aria-label="Erklärung anzeigen"
>
<IconInfoCircle size={14} />
</button>
{offen && (
<span
role="tooltip"
className="absolute bottom-full left-1/2 z-20 mb-1.5 w-56 -translate-x-1/2 rounded-lg bg-slate-800 px-2.5 py-1.5 text-[11px] font-normal normal-case leading-snug text-white shadow-lg"
>
{text}
<span className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-slate-800" />
</span>
)}
</span>
);
}
+63 -1
View File
@@ -7,6 +7,7 @@ export type NutzerAdmin = {
id: number;
benutzername: string;
name: string | null;
email: string | null;
rolle: "nutzer" | "admin";
aktiv: boolean;
erstellt_am: string;
@@ -38,6 +39,12 @@ export type AuditEintrag = {
erstellt_am: string;
};
export type BackupEintrag = {
dateiname: string;
groesse_bytes: number;
erstellt_am: string;
};
export function useNutzerListe() {
return useQuery<NutzerAdmin[]>({
queryKey: ["admin", "nutzer"],
@@ -45,6 +52,60 @@ export function useNutzerListe() {
});
}
export function useBackups() {
return useQuery<BackupEintrag[]>({
queryKey: ["admin", "backups"],
queryFn: () => api<BackupEintrag[]>("/api/admin/backups"),
});
}
export function backupUrl(dateiname: string) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
return `${apiUrl}/api/admin/backups/${encodeURIComponent(dateiname)}`;
}
export function useBackupMutationen() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
const erstellen = useMutation({
mutationFn: (begruendung: string) =>
api<BackupEintrag>("/api/admin/backups", {
method: "POST",
body: JSON.stringify({ begruendung }),
}),
onSuccess: invalidate,
});
const loeschen = useMutation({
mutationFn: (dateiname: string) =>
api(`/api/admin/backups/${encodeURIComponent(dateiname)}`, { method: "DELETE" }),
onSuccess: invalidate,
});
const wiederherstellen = useMutation({
mutationFn: async ({ archiv, begruendung }: { archiv: File; begruendung: string }) => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
const form = new FormData();
form.append("archiv", archiv);
form.append("begruendung", begruendung);
const res = await fetch(`${apiUrl}/api/admin/backups/restore`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || "Wiederherstellung fehlgeschlagen");
}
return res.json() as Promise<{ ok: boolean; sicherheitskopie_vorher: string }>;
},
onSuccess: invalidate,
});
return { erstellen, loeschen, wiederherstellen };
}
export function useAuditLog() {
return useQuery<AuditEintrag[]>({
queryKey: ["admin", "audit-log"],
@@ -64,6 +125,7 @@ export function useAdminMutationen() {
benutzername: string;
passwort: string;
name?: string;
email?: string;
rolle?: string;
}) => api("/api/nutzer", { method: "POST", body: JSON.stringify(payload) }),
onSuccess: invalidate,
@@ -75,7 +137,7 @@ export function useAdminMutationen() {
payload,
}: {
uid: number;
payload: { name: string; rolle: string; aktiv: boolean; passwort?: string; rg_name?: string; rg_strasse?: string; rg_plz?: string; rg_ort?: string };
payload: { name: string; email?: string; rolle: string; aktiv: boolean; passwort?: string; rg_name?: string; rg_strasse?: string; rg_plz?: string; rg_ort?: string };
}) => api(`/api/nutzer/${uid}`, { method: "PUT", body: JSON.stringify(payload) }),
onSuccess: invalidate,
});
+1 -1
View File
@@ -1,4 +1,4 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
export class ApiError extends Error {
status: number;
+20 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./api";
import { api, API_URL } from "./api";
export type Aufgabe = {
id: number;
@@ -15,6 +15,25 @@ export type Aufgabe = {
patient_name: string | null;
};
export function icsUrl(aufgabe: Aufgabe): string {
return `${API_URL}/api/aufgaben/${aufgabe.id}/ics`;
}
export function googleCalendarUrl(aufgabe: Aufgabe): string {
if (!aufgabe.faellig_am) return "";
const start = aufgabe.faellig_am.replace(/-/g, "");
const ende = new Date(aufgabe.faellig_am + "T00:00:00");
ende.setDate(ende.getDate() + 1);
const endeStr = ende.toISOString().slice(0, 10).replace(/-/g, "");
const params = new URLSearchParams({
action: "TEMPLATE",
text: aufgabe.titel,
dates: `${start}/${endeStr}`,
});
if (aufgabe.beschreibung) params.set("details", aufgabe.beschreibung);
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
export function useAufgaben() {
return useQuery<Aufgabe[]>({
queryKey: ["aufgaben"],
+35 -1
View File
@@ -8,12 +8,19 @@ export type Me = {
id: number;
benutzername: string;
name: string | null;
email: string | null;
rolle: "nutzer" | "admin";
ist_demo: boolean;
selbst_registriert: boolean;
app_version: string;
changelog_ungesehen: ChangelogEintrag[];
schreibzugriff_gesperrt: boolean;
rg_name: string | null;
rg_strasse: string | null;
rg_plz: string | null;
rg_ort: string | null;
reminder_aktiv: boolean;
reminder_uhrzeit: string | null;
};
export function useAuth() {
@@ -45,10 +52,13 @@ export function useAuth() {
benutzername: string;
passwort: string;
name: string;
email: string;
rg_name: string;
rg_strasse: string;
rg_plz: string;
rg_ort: string;
zustimmung_sofortbeginn: boolean;
zustimmung_widerrufsverzicht: boolean;
}) => api("/api/auth/registrieren", { method: "POST", body: JSON.stringify(payload) }),
onSuccess: invalidateMe,
});
@@ -58,5 +68,29 @@ export function useAuth() {
onSuccess: invalidateMe,
});
return { meQuery, login, registrieren, logout };
const passwortVergessen = useMutation({
mutationFn: (payload: { email: string }) =>
api("/api/auth/passwort-vergessen", { method: "POST", body: JSON.stringify(payload) }),
});
const passwortZuruecksetzen = useMutation({
mutationFn: (payload: { token: string; neues_passwort: string }) =>
api("/api/auth/passwort-zuruecksetzen", { method: "POST", body: JSON.stringify(payload) }),
});
const reminderEinstellungenSetzen = useMutation({
mutationFn: (payload: { reminder_aktiv: boolean; reminder_uhrzeit: string | null }) =>
api<Me>("/api/auth/reminder-einstellungen", { method: "PATCH", body: JSON.stringify(payload) }),
onSuccess: invalidateMe,
});
return {
meQuery,
login,
registrieren,
logout,
passwortVergessen,
passwortZuruecksetzen,
reminderEinstellungenSetzen,
};
}
+36 -1
View File
@@ -39,6 +39,17 @@ export function useWiderspruchGruende() {
});
}
export type ChecklistePunkt = { typ: string; label: string };
export function useAntragCheckliste(typ: string) {
return useQuery<ChecklistePunkt[]>({
queryKey: ["antrag-checkliste", typ],
queryFn: () => api<ChecklistePunkt[]>(`/api/antraege/checkliste?typ=${encodeURIComponent(typ)}`),
enabled: !!typ,
staleTime: Infinity,
});
}
export function useAntraegeFuerPatient(pid: number) {
return useQuery<Antrag[]>({
queryKey: ["antraege", "patient", pid],
@@ -81,7 +92,31 @@ export function useAntragMutationen(pid: number) {
onSuccess: invalidate,
});
return { anlegen, widerspruchErstellen, musterbriefErstellen };
const vollmachtErstellen = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
api<Antrag>(`/api/patienten/${pid}/vollmacht`, {
method: "POST",
body: JSON.stringify(payload),
}),
onSuccess: invalidate,
});
const patientenverfuegungErstellen = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
api<Antrag>(`/api/patienten/${pid}/patientenverfuegung`, {
method: "POST",
body: JSON.stringify(payload),
}),
onSuccess: invalidate,
});
return {
anlegen,
widerspruchErstellen,
musterbriefErstellen,
vollmachtErstellen,
patientenverfuegungErstellen,
};
}
export type AdressaenderungErgebnis = {
+40 -1
View File
@@ -23,6 +23,16 @@ export function useDokumente(pid: number) {
});
}
export type DokumentTyp = { typ: string; label: string };
export function useDokumentTypen() {
return useQuery<DokumentTyp[]>({
queryKey: ["dokument-typen"],
queryFn: () => api<DokumentTyp[]>("/api/dokumente/typen"),
staleTime: Infinity,
});
}
export function dokumentUrl(pid: number, did: number) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
return `${apiUrl}/api/patienten/${pid}/dokumente/${did}/datei`;
@@ -61,10 +71,39 @@ export function useDokumentMutationen(pid: number) {
onSuccess: invalidate,
});
const scanHochladen = useMutation({
mutationFn: async ({
dateien,
typ,
bezeichnung,
}: {
dateien: File[];
typ: string;
bezeichnung: string;
}) => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
const form = new FormData();
for (const datei of dateien) form.append("dateien", datei);
form.append("typ", typ);
form.append("bezeichnung", bezeichnung);
const res = await fetch(`${apiUrl}/api/patienten/${pid}/dokumente/scan`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || "Scan-Upload fehlgeschlagen");
}
return res.json();
},
onSuccess: invalidate,
});
const loeschen = useMutation({
mutationFn: (did: number) => api(`/api/dokumente/${did}`, { method: "DELETE" }),
onSuccess: invalidate,
});
return { hochladen, loeschen };
return { hochladen, scanHochladen, loeschen };
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useMutation } from "@tanstack/react-query";
export type EgkScanErgebnis = {
vorname: string | null;
nachname: string | null;
geburtsdatum: string | null;
versichertennummer: string | null;
krankenkasse: string | null;
};
export function useEgkScanMutation() {
return useMutation({
mutationFn: async (dateien: File[]) => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8100";
const form = new FormData();
for (const datei of dateien) form.append("dateien", datei);
const res = await fetch(`${apiUrl}/api/egk/scan`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || "Kartenerkennung fehlgeschlagen");
}
return res.json() as Promise<EgkScanErgebnis>;
},
});
}
@@ -13,6 +13,10 @@ export type PublicEinstellungen = {
rechtstext_impressum?: string;
rechtstext_datenschutz?: string;
rechtstext_agb?: string;
landing_hero_titel?: string;
landing_hero_text?: string;
landing_seo_title?: string;
landing_seo_description?: string;
abo_preis_basis: string;
abo_preis_zusatzpatient: string;
abo_waehrung: string;
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
export type KrisenGuide = {
id: string;
titel: string;
kurz: string;
schritte: string[];
};
export function useKrisenGuides() {
return useQuery<KrisenGuide[]>({
queryKey: ["krisenguides"],
queryFn: () => api<KrisenGuide[]>("/api/krisenguides"),
staleTime: Infinity,
});
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more