diff --git a/pflegepilot_v3/backend/app/antrag_token.py b/pflegepilot_v3/backend/app/antrag_token.py new file mode 100644 index 0000000..9102d7b --- /dev/null +++ b/pflegepilot_v3/backend/app/antrag_token.py @@ -0,0 +1,44 @@ +"""Magic-Link-Token fuer die Antragsstatus-Erinnerungsmail (T25) - gleiches +Muster wie der Passwort-Reset-Token in routers/auth.py: ein Klartext-Token +geht nur in den E-Mail-Link, persistiert wird ausschliesslich der SHA-256- +Hash. Ein Token deckt alle drei Ergebnis-Links einer Mail ab (bewilligt/ +abgelehnt/offen kommt als eigener Query-Parameter dazu) - Einmalgebrauch: +sobald ein Link angeklickt wurde, sind die anderen beiden ungueltig. +""" + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from app.models import Antrag, AntragAktionsToken + +TOKEN_GUELTIGKEIT = timedelta(days=14) + + +def erstelle_token(db: Session, antrag: Antrag) -> str: + token = secrets.token_urlsafe(32) + eintrag = AntragAktionsToken( + antrag_id=antrag.id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + laeuft_ab=datetime.now(timezone.utc) + TOKEN_GUELTIGKEIT, + ) + db.add(eintrag) + db.flush() + return token + + +def token_einloesen(db: Session, token: str) -> Antrag | None: + token_hash = hashlib.sha256(token.encode()).hexdigest() + eintrag = ( + db.query(AntragAktionsToken).filter(AntragAktionsToken.token_hash == token_hash).first() + ) + if ( + eintrag is None + or eintrag.eingeloest_am is not None + or eintrag.laeuft_ab < datetime.now(timezone.utc) + ): + return None + eintrag.eingeloest_am = datetime.now(timezone.utc) + return eintrag.antrag diff --git a/pflegepilot_v3/backend/app/main.py b/pflegepilot_v3/backend/app/main.py index 721418e..4ee19bb 100644 --- a/pflegepilot_v3/backend/app/main.py +++ b/pflegepilot_v3/backend/app/main.py @@ -86,6 +86,7 @@ app.include_router(stammdaten.router) app.include_router(stammdaten.rv_router) app.include_router(katalog.router) app.include_router(antraege.router) +app.include_router(antraege.router_public) app.include_router(briefe.router) app.include_router(admin.router) app.include_router(abo.router) diff --git a/pflegepilot_v3/backend/app/static_data/antrag_fristen.py b/pflegepilot_v3/backend/app/static_data/antrag_fristen.py new file mode 100644 index 0000000..11fd451 --- /dev/null +++ b/pflegepilot_v3/backend/app/static_data/antrag_fristen.py @@ -0,0 +1,57 @@ +"""Antragsstatus-Tracking (T25): je Antragstyp unterschiedliches Intervall, +nach dem erstmalig per E-Mail nachgefragt wird, ob inzwischen ein Bescheid +eingetroffen ist (siehe app/reminder_scheduler.py). Nur Typen, bei denen +tatsaechlich ein Bescheid einer Stelle erwartet wird - adressaenderung/ +vollmacht/patientenverfuegung sind bewusst nicht enthalten (kein Tracking). + +erstantrag/hoeherst orientieren sich an der 25-Arbeitstage-Regelfrist nach +§ 18 Abs. 3 SGB XI (plus Puffer fuer Postlaufzeit/Wochenenden), die anderen +Werte sind pragmatische Schaetzwerte ohne feste gesetzliche Bearbeitungsfrist. +""" + +import datetime + +ANTRAG_ERINNERUNGS_TAGE: dict[str, int] = { + "erstantrag": 35, + "hoeherst": 35, + "widerspruch": 56, + "eingliederung": 42, + "grundsicherung": 42, + "blindenhilfe": 42, +} + +# Wiederholungsintervall, falls nach der ersten Erinnerung immer noch keine +# Antwort eingetragen wurde, sowie ein Cutoff (ab wann die Serie ganz +# abbricht, um niemanden endlos zu behelligen). +ERINNERUNG_WIEDERHOLUNG_TAGE = 21 +ERINNERUNG_CUTOFF_TAGE = 180 + +# Freundliche deutsche Bezeichnung je Antragstyp fuer E-Mail-Texte (Pendant +# zum ANTRAG_TYP_LABEL im Frontend, das Dashboard/Patientenakte fuer die +# Anzeige nutzen - hier eine eigene, serverseitige Kopie fuer Mailtexte). +ANTRAG_TYP_LABEL_DE: dict[str, str] = { + "erstantrag": "Erstantrag Pflegegrad", + "hoeherst": "Höherstufung Pflegegrad", + "widerspruch": "Widerspruch", + "adressaenderung": "Adressänderung", + "vollmacht": "Vollmacht", + "patientenverfuegung": "Patientenverfügung", + "eingliederung": "Eingliederungshilfe", + "grundsicherung": "Grundsicherung", + "blindenhilfe": "Blindenhilfe", +} + + +def berechne_naechste_erinnerung( + typ: str, bezugsdatum: datetime.date | None = None +) -> datetime.date | None: + """Faelligkeitsdatum der ersten "ist der Bescheid da?"-Mail fuer einen + neu angelegten Antrag - None fuer Typen ohne Tracking (siehe oben). + bezugsdatum ist das Antragsdatum, falls bekannt (z.B. beim manuellen + Nachtragen eines extern gestellten Antrags); ohne Angabe wird das + Erstellungsdatum in der App als Basis genommen.""" + tage = ANTRAG_ERINNERUNGS_TAGE.get(typ) + if tage is None: + return None + basis = bezugsdatum or datetime.date.today() + return basis + datetime.timedelta(days=tage) diff --git a/pflegepilot_v3/backend/migrations/versions/a1c4e7f9b3d6_antragsstatus_tracking.py b/pflegepilot_v3/backend/migrations/versions/a1c4e7f9b3d6_antragsstatus_tracking.py new file mode 100644 index 0000000..d93bfd4 --- /dev/null +++ b/pflegepilot_v3/backend/migrations/versions/a1c4e7f9b3d6_antragsstatus_tracking.py @@ -0,0 +1,51 @@ +"""antragsstatus-tracking: bewilligter betrag, aktenzeichen, erinnerungen + +Revision ID: a1c4e7f9b3d6 +Revises: d7f1a3c5e8b2 +Create Date: 2026-07-31 00:00:00.000000 + +Tester-Feedback T25: periodische "ist der Bescheid da?"-Erinnerung per +E-Mail (Magic-Link, Einmal-Token), Erfassung des bewilligten Betrags, sowie +eine Eskalationserinnerung kurz vor Ablauf der Widerspruchsfrist. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1c4e7f9b3d6' +down_revision = 'd7f1a3c5e8b2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('antraege', sa.Column('bewilligter_betrag', sa.Numeric(10, 2), nullable=True)) + op.add_column('antraege', sa.Column('aktenzeichen', sa.String(100), nullable=True)) + op.add_column('antraege', sa.Column('naechste_statuserinnerung_am', sa.Date(), nullable=True)) + op.add_column( + 'antraege', + sa.Column( + 'widerspruch_eskalation_gesendet', sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + + op.create_table( + 'antrag_aktions_token', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column( + 'antrag_id', sa.Integer(), sa.ForeignKey('antraege.id', ondelete='CASCADE'), nullable=False + ), + sa.Column('token_hash', sa.String(64), nullable=False, unique=True), + sa.Column('laeuft_ab', sa.DateTime(timezone=True), nullable=False), + sa.Column('eingeloest_am', sa.DateTime(timezone=True), nullable=True), + sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table('antrag_aktions_token') + op.drop_column('antraege', 'widerspruch_eskalation_gesendet') + op.drop_column('antraege', 'naechste_statuserinnerung_am') + op.drop_column('antraege', 'aktenzeichen') + op.drop_column('antraege', 'bewilligter_betrag') diff --git a/pflegepilot_v3/frontend/src/app/(app)/antraege/[id]/keine-sorge/page.tsx b/pflegepilot_v3/frontend/src/app/(app)/antraege/[id]/keine-sorge/page.tsx new file mode 100644 index 0000000..85d34a1 --- /dev/null +++ b/pflegepilot_v3/frontend/src/app/(app)/antraege/[id]/keine-sorge/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { + useAntrag, + useAntragMutationen, + useWiderspruchGruende, + pdfUrl, +} from "@/lib/briefe"; +import { usePatient } from "@/lib/patienten"; +import { buttonStyles, accentForKey, getAccent } from "@/lib/theme"; + +const AUTOMATISIERTE_TYPEN = ["erstantrag", "hoeherst"]; + +const HAEUFIGE_GRUENDE = [ + "Die Begutachtung war zu kurz oder fand nur telefonisch statt, statt bei einem persönlichen Hausbesuch.", + "Das Pflegetagebuch oder ärztliche Nachweise wurden bei der Begutachtung nicht berücksichtigt.", + "Der tatsächliche Unterstützungsbedarf – z. B. bei Mobilität, Selbstversorgung oder nachts – wurde unterschätzt.", + "Der Bescheid ist nicht ausreichend begründet worden, sodass unklar bleibt, worauf die Entscheidung beruht.", +]; + +function fristInfo(bescheidDatum: string | null, widerspruchFrist: string | null) { + const frist = widerspruchFrist + ? new Date(widerspruchFrist) + : bescheidDatum + ? new Date(new Date(bescheidDatum).getTime() + 31 * 24 * 60 * 60 * 1000) + : null; + if (!frist) return null; + const heute = new Date(); + const tage = Math.ceil((frist.getTime() - heute.getTime()) / (24 * 60 * 60 * 1000)); + const farbe = tage <= 3 ? "text-red-700 bg-red-50 border-red-200" : tage <= 14 ? "text-amber-800 bg-amber-50 border-amber-200" : "text-emerald-700 bg-emerald-50 border-emerald-200"; + return { frist, tage, farbe }; +} + +export default function KeineSorgePage() { + const params = useParams(); + const aid = Number(params.id); + const { data: antrag } = useAntrag(aid); + const { data: patient } = usePatient(antrag?.patient_id ?? 0); + const { data: gruendeData } = useWiderspruchGruende(); + const { widerspruchErstellen } = useAntragMutationen(patient?.id ?? 0); + + const [sollGrad, setSollGrad] = useState(3); + const [gewaehlteGruende, setGewaehlteGruende] = useState>(new Set()); + const [beweismittel, setBeweismittel] = useState>(new Set()); + + const gruppenNorm = useMemo(() => { + if (!gruendeData) return []; + const seen = new Map(); + for (const g of gruendeData.gruende) if (!seen.has(g.norm)) seen.set(g.norm, g.group); + return [...seen.entries()]; + }, [gruendeData]); + + const toggleGrund = (id: string) => { + setGewaehlteGruende((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + const toggleBeweis = (label: string) => { + setBeweismittel((prev) => { + const next = new Set(prev); + next.has(label) ? next.delete(label) : next.add(label); + return next; + }); + }; + + if (!antrag || !patient) { + return
Lädt…
; + } + + const info = fristInfo(antrag.bescheid_datum, antrag.widerspruch_frist); + const automatisiert = AUTOMATISIERTE_TYPEN.includes(antrag.typ); + const ergebnisAccent = getAccent("emerald"); + + const patientName = `${patient.vorname} ${patient.nachname}`; + const adresse = [patient.strasse, [patient.plz, patient.ort].filter(Boolean).join(", ")] + .filter(Boolean) + .join(", "); + + const vollformularUrl = () => { + // name/adresse/geburtsdatum/versichertennummer/krankenkasse muessen hier + // nicht mitgegeben werden - /widerspruch fuellt sie beim Waehlen von + // patientId ohnehin aus demselben Patientendatensatz (patientWaehlen()). + const p = new URLSearchParams(); + p.set("patientId", String(patient.id)); + if (antrag.bescheid_datum) p.set("bescheiddatum", antrag.bescheid_datum); + if (antrag.aktenzeichen) p.set("aktenzeichen", antrag.aktenzeichen); + if (antrag.zugeteilter_pflegegrad != null) p.set("istGrad", String(antrag.zugeteilter_pflegegrad)); + p.set("sollGrad", String(sollGrad)); + if (gewaehlteGruende.size > 0) p.set("grundIds", [...gewaehlteGruende].join(",")); + if (beweismittel.size > 0) p.set("beweismittel", [...beweismittel].join(",")); + return `/widerspruch?${p.toString()}`; + }; + + const erstellen = () => { + if (!antrag.bescheid_datum || gewaehlteGruende.size === 0) return; + widerspruchErstellen.mutate({ + patient_id: patient.id, + name: patientName, + adresse, + geburtsdatum: patient.geburtsdatum, + versichertennummer: patient.versichertennummer, + krankenkasse: patient.krankenkasse, + bescheiddatum: antrag.bescheid_datum, + aktenzeichen: antrag.aktenzeichen, + ist_grad: antrag.zugeteilter_pflegegrad ?? 0, + soll_grad: sollGrad, + grund_ids: [...gewaehlteGruende], + beweismittel: [...beweismittel], + }); + }; + + return ( +
+

+ Der Bescheid wurde abgelehnt – aber das ist nicht das Ende +

+

+ Ablehnungen kommen häufig vor und liegen oft an nachvollziehbaren, angreifbaren Gründen – nicht + daran, dass tatsächlich kein Bedarf besteht. Ein Widerspruch kostet nichts und lohnt sich in + vielen Fällen. Häufige Gründe, warum Bescheide zu Recht angefochten werden: +

+
    + {HAEUFIGE_GRUENDE.map((g) => ( +
  • + • + {g} +
  • + ))} +
+ + {info && ( +

+ Widerspruchsfrist (§ 84 SGG): bis {info.frist.toLocaleDateString("de-DE")} + {info.tage >= 0 ? ` – noch ${info.tage} Tage Zeit` : " – die Frist ist bereits abgelaufen, ein Widerspruch kann trotzdem noch sinnvoll sein"} + . +

+ )} + + {!automatisiert ? ( +
+

+ Für diese Art von Antrag steht noch kein automatisch vorausgefüllter Widerspruch zur + Verfügung. Nutzen Sie das allgemeine Widerspruchs-Formular. +

+ + Zum allgemeinen Widerspruchs-Formular + +
+ ) : ( +
+
+

Widerspruch vorbereiten

+ + Vollständiges Formular öffnen + +
+

+ Für: {patientName} + {patient.krankenkasse ? ` · ${patient.krankenkasse}` : ""} + {antrag.bescheid_datum ? ` · Bescheid vom ${new Date(antrag.bescheid_datum).toLocaleDateString("de-DE")}` : ""} +

+ +
+
+ +

+ {antrag.zugeteilter_pflegegrad ? `Grad ${antrag.zugeteilter_pflegegrad}` : "Kein Grad"} +

+
+
+ + +
+
+ +

Widerspruchsgründe auswählen *

+
+ {gruppenNorm.map(([norm, group]) => { + const accent = getAccent(accentForKey(norm)); + return ( +
+

+ {norm} – {group} +

+
+ {gruendeData!.gruende + .filter((g) => g.norm === norm) + .map((g) => ( + + ))} +
+
+ ); + })} +
+ +

Beigefügte Beweismittel

+
+ {gruendeData?.beweismittel_optionen.map((b) => ( + + ))} +
+ + {!widerspruchErstellen.data ? ( + <> + + {widerspruchErstellen.isError && ( +

{(widerspruchErstellen.error as Error).message}

+ )} + + ) : ( +
+

Widerspruch erstellt ✓

+ + PDF öffnen + +
+ )} +
+ )} +
+ ); +} diff --git a/pflegepilot_v3/frontend/src/app/(marketing)/antrag-status/page.tsx b/pflegepilot_v3/frontend/src/app/(marketing)/antrag-status/page.tsx new file mode 100644 index 0000000..34bbdcf --- /dev/null +++ b/pflegepilot_v3/frontend/src/app/(marketing)/antrag-status/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Suspense, useEffect, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useAntragAktionEinloesen, type AntragAktionErgebnis } from "@/lib/briefe"; +import { ApiError } from "@/lib/api"; + +function AntragStatusInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + const ergebnis = (searchParams.get("ergebnis") ?? "offen") as AntragAktionErgebnis; + const einloesen = useAntragAktionEinloesen(); + const gestartet = useRef(false); + + useEffect(() => { + if (!token || gestartet.current) return; + gestartet.current = true; + einloesen.mutate( + { token, ergebnis }, + { + onSuccess: (data) => { + if (data.ergebnis === "bewilligt") { + router.replace(`/patienten/${data.patient_id}?antragBetrag=${data.antrag_id}`); + } else if (data.ergebnis === "abgelehnt") { + router.replace(`/antraege/${data.antrag_id}/keine-sorge`); + } else { + router.replace(`/patienten/${data.patient_id}`); + } + }, + } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + if (!token) { + return ( +

+ Dieser Link ist unvollständig. Bitte den Link direkt aus der E-Mail öffnen. +

+ ); + } + + if (einloesen.isError) { + const message = + einloesen.error instanceof ApiError ? einloesen.error.message : "Unbekannter Fehler"; + return ( +
+

Link ungültig oder abgelaufen: {message}

+ + Zur Anmeldung + +
+ ); + } + + return

Einen Moment, wir tragen den Status ein…

; +} + +export default function AntragStatusPage() { + return ( +
+

Antragsstatus

+ Lädt…

}> + +
+
+ ); +}