From a73905cff1c4b2764b33d30de494d8a6cc96309a Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Mon, 10 Aug 2026 20:42:23 +0200 Subject: [PATCH] pflegepilot_v3: seniorenfreundliches Zweit-Design des Interview-Assistenten (Testroute) Zweite Design-Variante des "Neuer Patient"-Interviews unter /patienten/neu-senioren - eigene, komplett unverlinkte Route, bewusst kopiert statt geteilt, damit die produktive Route /patienten/neu unangetastet bleibt. Grosse Schrift, hoher Kontrast, groessere Bedienflaechen fuer aeltere Nutzer:innen. - Skalierung per CSS `zoom` auf dem Aussen-Container statt harter Groessen- Klassen, damit auch importierte Unterkomponenten (DateInput, EgkScan, KrankenkasseAutocomplete, InfoTooltip) automatisch mitskalieren. - Kontrastreichere Textfarben, dickere Rahmen, groessere Checkboxen/Buttons. - Aktueller Schrittname jetzt als sichtbarer Text statt nur als Hover-Tooltip. - Gelber Hinweisbanner "Testversion" oben auf der Seite. - Verifiziert per echtem Browser-Test (lokale Docker-Instanz) durch alle 7 Schritte des Assistenten; next build/tsc/eslint fehlerfrei. Co-Authored-By: Claude Sonnet 5 --- .../app/(app)/patienten/neu-senioren/page.tsx | 1223 +++++++++++++++++ 1 file changed, 1223 insertions(+) create mode 100644 pflegepilot_v3/frontend/src/app/(app)/patienten/neu-senioren/page.tsx diff --git a/pflegepilot_v3/frontend/src/app/(app)/patienten/neu-senioren/page.tsx b/pflegepilot_v3/frontend/src/app/(app)/patienten/neu-senioren/page.tsx new file mode 100644 index 0000000..ed6fbc0 --- /dev/null +++ b/pflegepilot_v3/frontend/src/app/(app)/patienten/neu-senioren/page.tsx @@ -0,0 +1,1223 @@ +"use client"; + +// TEST-VARIANTE (Design B) - grosse Schrift, hoher Kontrast, groessere +// Bedienflaechen fuer die aeltere Nutzergruppe. Bewusst eine komplette Kopie +// von (app)/patienten/neu/page.tsx statt einer gemeinsamen Abstraktion, damit +// die produktive Seite garantiert unangetastet bleibt - reine Testroute, +// absichtlich nirgends in der Navigation verlinkt (nur per direktem Link +// erreichbar: /patienten/neu-senioren). Gleiche Fach-/Speicherlogik wie das +// Original, nur die JSX-/Style-Ebene wurde angepasst. Skalierung ueber CSS +// `zoom` (nicht `transform:scale`) auf dem Aussen-Container, damit auch die +// importierten Unterkomponenten (DateInput, EgkScan, KrankenkasseAutocomplete, +// InfoTooltip) automatisch mitskalieren, ohne dass diese fuer beide Varianten +// dupliziert werden muessten - `zoom` veraendert (anders als `transform`) +// echte Layout-/Boxgroessen, dadurch kein Ueberlappen/Abschneiden. + +import { useEffect, useReducer, useState } from "react"; +import { useRouter } from "next/navigation"; +import { usePatientMutations } from "@/lib/patienten"; +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 { DateInput } from "@/components/DateInput"; +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", + "Kontakt & Adresse", + "Krankenversicherung", + "Pflegesituation", + "Einschätzung & Pflegeperson", + "Leistungen", + "Übersicht", +]; + +// Groessere, kontrastreichere Bedienelemente fuer diese Design-Variante - +// bewusst lokal statt der geteilten buttonStyles aus lib/theme.ts, damit hier +// durchgaengig groessere Schrift/Touch-Flaechen gelten koennen, ohne die dort +// zentral genutzten (kleineren) Stile fuer die restliche App zu veraendern. +const seniorBtn = { + primary: + "inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-7 py-4 text-xl font-bold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50", + secondary: + "inline-flex items-center justify-center gap-2 rounded-xl border-2 border-slate-500 bg-white px-7 py-4 text-xl font-bold text-slate-800 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-0", + success: + "inline-flex items-center justify-center gap-2 rounded-xl border-2 border-emerald-400 bg-emerald-50 px-5 py-3 text-lg font-bold text-emerald-800 transition hover:bg-emerald-100", + small: + "inline-flex items-center justify-center gap-2 rounded-xl border-2 border-slate-400 bg-white px-4 py-2.5 text-base font-bold text-slate-700 transition hover:bg-slate-50", +}; + +type WizardData = { + vorname: string; + nachname: string; + geburtsdatum: string | null; + strasse: string; + plz: string; + ort: string; + telefon: string; + email: string; + krankenkasse: string; + versichertennummer: string; + rentenversicherungsnummer: string; + versicherungsart: string; + pflegeperson: string; + notizen: string; + pflegegrad: number; +}; + +type WizardState = { + step: number; + q4sub: number; + data: WizardData; + antworten: Record; + zielgruppe: string; + ausgewaehlteLeistungen: Set; +}; + +const initialState: WizardState = { + step: 1, + q4sub: 0, + ausgewaehlteLeistungen: new Set(), + data: { + vorname: "", + nachname: "", + geburtsdatum: null, + strasse: "", + plz: "", + ort: "", + telefon: "", + email: "", + krankenkasse: "", + versichertennummer: "", + rentenversicherungsnummer: "", + versicherungsart: "GKV", + pflegeperson: "", + notizen: "", + pflegegrad: 0, + }, + antworten: {}, + zielgruppe: "alterspflege", +}; + +type Action = + | { type: "SET_FIELD"; field: keyof WizardData; value: string | number | null } + | { type: "SET_ZIELGRUPPE"; zg: string } + | { type: "SET_ANTWORT"; key: string; wert: number } + | { type: "TOGGLE_LEISTUNG"; id: string } + | { type: "SET_LEISTUNGEN"; add: string[]; remove: string[] } + | { type: "NEXT"; total: number } + | { type: "BACK"; total: number }; + +function reducer(state: WizardState, action: Action): WizardState { + switch (action.type) { + case "SET_FIELD": + return { ...state, data: { ...state.data, [action.field]: action.value } }; + 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_LEISTUNGEN": { + const next = new Set(state.ausgewaehlteLeistungen); + action.remove.forEach((id) => next.delete(id)); + action.add.forEach((id) => next.add(id)); + return { ...state, ausgewaehlteLeistungen: next }; + } + case "SET_ZIELGRUPPE": + return { ...state, zielgruppe: action.zg }; + case "SET_ANTWORT": { + const antworten = { ...state.antworten, [action.key]: action.wert }; + return { ...state, antworten }; + } + case "NEXT": { + if (state.step === 4 && state.zielgruppe !== "beduerftige" && state.q4sub < action.total - 1) { + return { ...state, q4sub: state.q4sub + 1 }; + } + if (state.step < STEP_TITEL.length) { + const step = state.step + 1; + return { ...state, step, q4sub: step === 4 ? 0 : state.q4sub }; + } + return state; + } + case "BACK": { + if (state.step === 4 && state.zielgruppe !== "beduerftige" && state.q4sub > 0) { + return { ...state, q4sub: state.q4sub - 1 }; + } + if (state.step > 1) { + const step = state.step - 1; + return { + ...state, + step, + q4sub: step === 4 ? (state.zielgruppe === "beduerftige" ? 0 : action.total - 1) : state.q4sub, + }; + } + return state; + } + default: + return state; + } +} + +export default function NeuerPatientSeniorenPage() { + 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(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 [adresseWieKonto, setAdresseWieKonto] = useState(false); + const me = meQuery.data; + const kontoAdresse = + me?.rg_strasse && me?.rg_plz && me?.rg_ort + ? { strasse: me.rg_strasse, plz: me.rg_plz, ort: me.rg_ort } + : null; + + const adresseWieKontoUmschalten = (checked: boolean) => { + setAdresseWieKonto(checked); + if (checked && kontoAdresse) { + dispatch({ type: "SET_FIELD", field: "strasse", value: kontoAdresse.strasse }); + dispatch({ type: "SET_FIELD", field: "plz", value: kontoAdresse.plz }); + dispatch({ type: "SET_FIELD", field: "ort", value: kontoAdresse.ort }); + } else if (!checked) { + dispatch({ type: "SET_FIELD", field: "strasse", value: "" }); + dispatch({ type: "SET_FIELD", field: "plz", value: "" }); + dispatch({ type: "SET_FIELD", field: "ort", value: "" }); + } + }; + + 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) { + setVersichertennummerBeruehrt(true); + return; + } + setError(null); + dispatch({ type: "NEXT", total }); + }; + const back = () => dispatch({ type: "BACK", total }); + + useEffect(() => { + if (state.step === 5 && Object.keys(state.antworten).length > 0) { + schaetzung.mutate(state.antworten); + } + // 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 }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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, + geburtsdatum: state.data.geburtsdatum ?? undefined, + strasse: state.data.strasse || undefined, + plz: state.data.plz || undefined, + ort: state.data.ort || undefined, + telefon: state.data.telefon || undefined, + email: state.data.email || undefined, + krankenkasse: state.data.krankenkasse || undefined, + versichertennummer: state.data.versichertennummer || undefined, + rentenversicherungsnummer: state.data.rentenversicherungsnummer || undefined, + versicherungsart: state.data.versicherungsart, + pflegeperson: state.data.pflegeperson || undefined, + notizen: state.data.notizen || undefined, + pflegegrad: state.data.pflegegrad, + zielgruppe: state.zielgruppe, + berechtigung_bestaetigt: berechtigungBestaetigt, + }); + + 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 ( +
+
+ Testversion „Große Schrift“ – nur zum internen Ausprobieren, nicht verlinkt. +
+ + {/* zoom statt transform:scale, damit importierte Unterkomponenten + (DateInput, EgkScan, KrankenkasseAutocomplete, InfoTooltip) ohne + eigene Kopie automatisch mitskalieren und die Boxgroesse im Layout + korrekt mitwaechst (kein Ueberlappen wie bei transform:scale). */} +
+

Neuer Patient

+

+ Ein kurzes Interview – ohne KI, nur einfache Fragen zur Pflegesituation. +

+ + + +
+ {state.step === 1 && } + {state.step === 2 && ( + + )} + {state.step === 3 && ( + setVersichertennummerBeruehrt(true)} + /> + )} + {state.step === 4 && + (fragenLoading || !fragenData ? ( +

Lädt…

+ ) : ( + + ))} + {state.step === 5 && ( + + )} + {state.step === 6 && } + {state.step === 7 && ( + + )} + + {error &&

{error}

} + +
+ + {state.step < 7 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function StepDots({ current }: { current: number }) { + return ( +
+
+ {STEP_TITEL.map((t, i) => { + const n = i + 1; + const done = n < current; + const on = n === current; + return ( +
+
+ {done ? "✓" : n} +
+ {n < STEP_TITEL.length &&
} +
+ ); + })} +
+ {/* Aktueller Schrittname sichtbar als Text statt nur als Hover-Tooltip + (title-Attribut) - fuer Touch-Bedienung/aeltere Nutzer nicht + verlaesslich erreichbar. */} +

+ Schritt {current} von {STEP_TITEL.length}: {STEP_TITEL[current - 1]} +

+
+ ); +} + +function Field({ + label, + value, + onChange, + required, + maxLength, + name, + autoComplete = "off", +}: { + label: string; + value: string; + onChange: (v: string) => void; + required?: boolean; + maxLength?: number; + name?: string; + autoComplete?: string; +}) { + return ( +
+ + onChange(e.target.value)} + maxLength={maxLength} + className="w-full rounded-xl border-2 border-slate-400 px-4 py-3 text-lg text-slate-900" + /> +
+ ); +} + +function Step1({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + 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 ( +
+

+ Wie heißt die Person, die Sie anmelden möchten? +

+ + dispatch({ type: "SET_FIELD", field: "vorname", value: v })} + /> + dispatch({ type: "SET_FIELD", field: "nachname", value: v })} + /> + dispatch({ type: "SET_FIELD", field: "geburtsdatum", value: iso })} + label="Geburtsdatum" + /> +
+ ); +} + +function Step2({ + state, + dispatch, + hatEigeneEmail, + onHatEigeneEmailChange, + kontoEmail, + adresseWieKonto, + onAdresseWieKontoChange, + kontoAdresse, +}: { + state: WizardState; + dispatch: React.Dispatch; + hatEigeneEmail: boolean; + onHatEigeneEmailChange: (checked: boolean) => void; + kontoEmail: string | null; + adresseWieKonto: boolean; + onAdresseWieKontoChange: (checked: boolean) => void; + kontoAdresse: { strasse: string; plz: string; ort: string } | null; +}) { + usePlzSync( + state.data.plz, + (v) => dispatch({ type: "SET_FIELD", field: "ort", value: v }), + !adresseWieKonto + ); + + return ( +
+ {kontoAdresse && ( + + )} + + {adresseWieKonto && kontoAdresse ? ( +
+ Es wird Ihre eigene Adresse verwendet:{" "} + + {kontoAdresse.strasse}, {kontoAdresse.plz} {kontoAdresse.ort} + +
+ ) : ( + <> + dispatch({ type: "SET_FIELD", field: "strasse", value: v })} + /> +
+
+ dispatch({ type: "SET_FIELD", field: "plz", value: v })} + maxLength={5} + /> +
+
+ dispatch({ type: "SET_FIELD", field: "ort", value: v })} + /> +
+
+ + )} + dispatch({ type: "SET_FIELD", field: "telefon", value: v })} + /> + + + + {!hatEigeneEmail && kontoEmail && ( +
+ Es wird stattdessen Ihre eigene E-Mail-Adresse verwendet:{" "} + {kontoEmail} +
+ )} + + {(hatEigeneEmail || !kontoEmail) && ( + dispatch({ type: "SET_FIELD", field: "email", value: v })} + /> + )} + + {!hatEigeneEmail && !kontoEmail && ( +

+ 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. +

+ )} +
+ ); +} + +function Step3({ + state, + dispatch, + fehler, + onVersichertennummerBlur, +}: { + state: WizardState; + dispatch: React.Dispatch; + fehler: string | null; + onVersichertennummerBlur: () => void; +}) { + return ( +
+ dispatch({ type: "SET_FIELD", field: "krankenkasse", value: v })} + /> +
+ + + dispatch({ type: "SET_FIELD", field: "versichertennummer", value: e.target.value }) + } + onBlur={onVersichertennummerBlur} + className={`w-full rounded-xl border-2 px-4 py-3 text-lg text-slate-900 ${ + fehler ? "border-red-500" : "border-slate-400" + }`} + /> + {fehler ? ( +

{fehler}

+ ) : ( +

+ Format hängt von der Krankenkasse ab (DE z. B. A123456789). +

+ )} +
+ dispatch({ type: "SET_FIELD", field: "rentenversicherungsnummer", value: v })} + /> +
+ + +
+
+ ); +} + +function TeilschritteAuswahl({ + frage, + antwortWert, + onChange, +}: { + frage: { key: string; frage: string; teilschritte: string[] }; + antwortWert: number | undefined; + onChange: (wert: number) => void; +}) { + const [checked, setChecked] = useState>(() => { + if (antwortWert === undefined) return new Set(); + const anzahlAngekreuzt = frage.teilschritte.length - antwortWert; + return new Set(Array.from({ length: anzahlAngekreuzt }, (_, i) => i)); + }); + + const toggle = (i: number) => { + const next = new Set(checked); + if (next.has(i)) next.delete(i); + else next.add(i); + setChecked(next); + onChange(frage.teilschritte.length - next.size); + }; + + return ( +
+

{frage.frage}

+

Was kann die Person noch selbstständig?

+
+ {frage.teilschritte.map((t, i) => ( + + ))} +
+
+ ); +} + +function Step4({ + state, + dispatch, + fragenData, + total, +}: { + state: WizardState; + dispatch: React.Dispatch; + fragenData: { fragen: { key: string; frage: string; teilschritte: string[] }[]; zielgruppen: { label: string; zg: string; hinweis: string }[] }; + total: number; +}) { + return ( +
+ {state.zielgruppe !== "beduerftige" && ( +
+ {Array.from({ length: total }, (_, i) => ( +
+ ))} +
+ )} + + {state.q4sub === 0 ? ( +
+

+ Worum geht es bei der Pflegesituation in erster Linie? +

+
+ {fragenData.zielgruppen.map((o) => ( + + ))} +
+ {state.zielgruppe === "beduerftige" && ( +

+ Für diese Auswahl ist keine Pflegegrad-Einschätzung vorgesehen – „Weiter“ + führt direkt zum nächsten Schritt. +

+ )} +
+ ) : ( + (() => { + const frage = fragenData.fragen[state.q4sub - 1]; + return ( + dispatch({ type: "SET_ANTWORT", key: frage.key, wert })} + /> + ); + })() + )} +
+ ); +} + +function Step5({ + state, + dispatch, + schaetzung, +}: { + state: WizardState; + dispatch: React.Dispatch; + schaetzung?: { summe: number; von: number; bis: number; vorschlag: number }; +}) { + return ( +
+
+ {state.zielgruppe === "beduerftige" ? ( + <> + Für diese Zielgruppe ist in der Regel keine Pflegegrad-Einschätzung{" "} + nach SGB XI vorgesehen – hier geht es meist um Grundsicherung oder andere + Sozialleistungen. Falls trotzdem bereits ein Pflegegrad festgestellt wurde, kann er + unten eingetragen werden. + + ) : schaetzung ? ( + <> + Grober Anhaltspunkt: Pflegegrad {schaetzung.von}–{schaetzung.bis}. + Das ist keine offizielle Feststellung – nur der Medizinische Dienst + kann per Begutachtung einen verbindlichen Pflegegrad bescheinigen. + + ) : ( + "Schätzung wird berechnet…" + )} +
+ +
+ +
+ {[0, 1, 2, 3, 4, 5].map((g) => ( + + ))} +
+
+ + dispatch({ type: "SET_FIELD", field: "pflegeperson", value: v })} + /> + +
+ +