feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
@@ -0,0 +1,433 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import Link from 'next/link'
import { AlertCircle, Loader2, Save } from 'lucide-react'
import { betreuteAendern, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
/** Prisma liefert Date, das Datumsfeld im Browser will "JJJJ-MM-TT". */
function alsDatumswert(datum: string | null | undefined): string {
if (!datum) return ''
return datum.slice(0, 10)
}
export type StammdatenWerte = {
id: string
vorname: string
nachname: string
geburtsname: string | null
geburtsdatum: string
geburtsort: string | null
anrede: string | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimTelefon: string | null
heimEinzugAm: string | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
krankenkasse: string | null
krankenversichertennummer: string | null
pflegekasse: string | null
pflegegrad: number | null
pflegegradSeit: string | null
pflegegradBescheidVom: string | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
diagnosen: string | null
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: string | null
bestellungBis: string | null
aufgabenkreise: string[]
aufgabenkreiseFreitext: string | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
aktenzeichenBezirk: string | null
aktenzeichenSozialamt: string | null
rentenversicherungsnummer: string | null
steuerId: string | null
notizen: string | null
}
function Speichern() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Speichern'}
</Button>
)
}
export function StammdatenFormular({ werte }: { werte: StammdatenWerte }) {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAendern, {})
/** Nach einem Fehlversuch gilt die Eingabe, sonst der gespeicherte Wert. */
const wert = (feld: keyof StammdatenWerte): string => {
const ausFehlversuch = zustand.werte?.[feld as string]
if (ausFehlversuch !== undefined) return ausFehlversuch
const gespeichert = werte[feld]
if (gespeichert === null || gespeichert === undefined) return ''
return String(gespeichert)
}
return (
<form action={aktion} className="space-y-6" noValidate>
<input type="hidden" name="betreuteId" value={werte.id} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{/* --- Person --------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Person</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="anrede" beschriftung="Anrede" fehler={zustand.felder?.anrede}>
<Input name="anrede" defaultValue={wert('anrede')} placeholder="Frau" />
</Feld>
<div className="hidden sm:block" />
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={wert('vorname')} required />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={wert('nachname')} required />
</Feld>
<Feld id="geburtsname" beschriftung="Geburtsname" fehler={zustand.felder?.geburtsname}>
<Input name="geburtsname" defaultValue={wert('geburtsname')} />
</Feld>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={alsDatumswert(wert('geburtsdatum'))}
required
/>
</Feld>
<Feld id="geburtsort" beschriftung="Geburtsort" fehler={zustand.felder?.geburtsort}>
<Input name="geburtsort" defaultValue={wert('geburtsort')} />
</Feld>
</CardContent>
</Card>
{/* --- Aufenthalt ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aufenthalt und Einrichtung</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="heimName" beschriftung="Name der Einrichtung" fehler={zustand.felder?.heimName}>
<Input name="heimName" defaultValue={wert('heimName')} />
</Feld>
<Feld id="heimZimmer" beschriftung="Zimmer" fehler={zustand.felder?.heimZimmer}>
<Input name="heimZimmer" defaultValue={wert('heimZimmer')} />
</Feld>
<Feld id="heimStrasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.heimStrasse}>
<Input name="heimStrasse" defaultValue={wert('heimStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="heimPlz" beschriftung="PLZ" fehler={zustand.felder?.heimPlz}>
<Input name="heimPlz" defaultValue={wert('heimPlz')} inputMode="numeric" />
</Feld>
<Feld id="heimOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.heimOrt}>
<Input name="heimOrt" defaultValue={wert('heimOrt')} />
</Feld>
</div>
<Feld id="heimTelefon" beschriftung="Telefon der Einrichtung" fehler={zustand.felder?.heimTelefon}>
<Input name="heimTelefon" type="tel" defaultValue={wert('heimTelefon')} />
</Feld>
<Feld id="heimEinzugAm" beschriftung="Eingezogen am" fehler={zustand.felder?.heimEinzugAm}>
<Input name="heimEinzugAm" type="date" defaultValue={alsDatumswert(wert('heimEinzugAm'))} />
</Feld>
<Feld
id="fruehereStrasse"
beschriftung="Frühere Straße"
erklaerung="Die letzte eigene Wohnanschrift. Behörden fragen danach."
fehler={zustand.felder?.fruehereStrasse}
>
<Input name="fruehereStrasse" defaultValue={wert('fruehereStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="frueherePlz" beschriftung="PLZ" fehler={zustand.felder?.frueherePlz}>
<Input name="frueherePlz" defaultValue={wert('frueherePlz')} inputMode="numeric" />
</Feld>
<Feld id="fruehererOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.fruehererOrt}>
<Input name="fruehererOrt" defaultValue={wert('fruehererOrt')} />
</Feld>
</div>
</CardContent>
</Card>
{/* --- Betreuungsgericht ---------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Betreuungsgericht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="betreuungsgericht" beschriftung="Gericht" fehler={zustand.felder?.betreuungsgericht}>
<Input name="betreuungsgericht" defaultValue={wert('betreuungsgericht')} />
</Feld>
<Feld id="aktenzeichenGericht" beschriftung="Aktenzeichen" fehler={zustand.felder?.aktenzeichenGericht}>
<Input name="aktenzeichenGericht" defaultValue={wert('aktenzeichenGericht')} />
</Feld>
<Feld
id="bestellungAm"
beschriftung="Bestellt am"
erklaerung="Steuert das Betreuungsjahr. Wird dieses Datum geändert, berechnet die Anwendung alle gesetzlichen Fristen neu."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={alsDatumswert(wert('bestellungAm'))} />
</Feld>
<Feld id="bestellungBis" beschriftung="Befristet bis" fehler={zustand.felder?.bestellungBis}>
<Input name="bestellungBis" type="date" defaultValue={alsDatumswert(wert('bestellungBis'))} />
</Feld>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<div className="grid gap-2 sm:grid-cols-2">
{AUFGABENKREISE.map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={werte.aufgabenkreise.includes(kreis)}
/>
<span className="text-sm">{AUFGABENKREIS_BESCHRIFTUNG[kreis]}</span>
</label>
))}
</div>
</fieldset>
<Feld
id="aufgabenkreiseFreitext"
beschriftung="Ergänzung zu den Aufgabenkreisen"
erklaerung="Falls die Bestellungsurkunde einen Bereich anders oder enger fasst."
fehler={zustand.felder?.aufgabenkreiseFreitext}
>
<Textarea name="aufgabenkreiseFreitext" defaultValue={wert('aufgabenkreiseFreitext')} rows={2} />
</Feld>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked={werte.ehrenamtlich} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Begründet den Anspruch auf die jährliche Aufwandspauschale.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked={werte.befreiterBetreuer} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer</span>
<span className="mt-0.5 block text-muted-foreground">
Dann genügt statt der vollständigen Rechnungslegung eine Vermögensübersicht.
</span>
</span>
</label>
</div>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
</CardContent>
</Card>
{/* --- Gesundheit ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Gesundheit und Pflege</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diagnosen, Versichertennummer und der ermittelte Wille sind besondere Daten nach
Art. 9 DSGVO. Sie werden zusätzlich verschlüsselt gespeichert und sind ohne den
Schlüssel aus der Serverumgebung auch aus einem Backup nicht lesbar.
</AlertDescription>
</Alert>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="krankenkasse" beschriftung="Krankenkasse" fehler={zustand.felder?.krankenkasse}>
<Input name="krankenkasse" defaultValue={wert('krankenkasse')} />
</Feld>
<Feld
id="krankenversichertennummer"
beschriftung="Versichertennummer"
fehler={zustand.felder?.krankenversichertennummer}
>
<Input name="krankenversichertennummer" defaultValue={wert('krankenversichertennummer')} />
</Feld>
<Feld id="pflegekasse" beschriftung="Pflegekasse" fehler={zustand.felder?.pflegekasse}>
<Input name="pflegekasse" defaultValue={wert('pflegekasse')} />
</Feld>
<Feld id="pflegegrad" beschriftung="Pflegegrad" erklaerung="1 bis 5" fehler={zustand.felder?.pflegegrad}>
<Input name="pflegegrad" type="number" min={1} max={5} defaultValue={wert('pflegegrad')} />
</Feld>
<Feld id="pflegegradSeit" beschriftung="Pflegegrad seit" fehler={zustand.felder?.pflegegradSeit}>
<Input name="pflegegradSeit" type="date" defaultValue={alsDatumswert(wert('pflegegradSeit'))} />
</Feld>
<Feld
id="pflegegradBescheidVom"
beschriftung="Bescheid vom"
fehler={zustand.felder?.pflegegradBescheidVom}
>
<Input
name="pflegegradBescheidVom"
type="date"
defaultValue={alsDatumswert(wert('pflegegradBescheidVom'))}
/>
</Feld>
<Feld
id="schwerbehinderungGdB"
beschriftung="Grad der Behinderung"
erklaerung="20 bis 100"
fehler={zustand.felder?.schwerbehinderungGdB}
>
<Input
name="schwerbehinderungGdB"
type="number"
min={20}
max={100}
step={10}
defaultValue={wert('schwerbehinderungGdB')}
/>
</Feld>
<Feld
id="schwerbehinderungMerkzeichen"
beschriftung="Merkzeichen"
erklaerung="z. B. G, aG, B, H, Bl, Gl"
fehler={zustand.felder?.schwerbehinderungMerkzeichen}
>
<Input
name="schwerbehinderungMerkzeichen"
defaultValue={wert('schwerbehinderungMerkzeichen')}
/>
</Feld>
</div>
<Feld
id="diagnosen"
beschriftung="Diagnosen"
erklaerung="Eine Diagnose je Zeile, gern mit Datum der Feststellung."
fehler={zustand.felder?.diagnosen}
>
<Textarea name="diagnosen" defaultValue={wert('diagnosen')} rows={4} />
</Feld>
<Feld
id="ermittelterWille"
beschriftung="Ermittelter Wille der betreuten Person"
erklaerung="Was möchte sie selbst? Seit der Reform 2023 ist das die Leitschnur deines Handelns — auch dann, wenn du es anders entscheiden würdest."
fehler={zustand.felder?.ermittelterWille}
>
<Textarea name="ermittelterWille" defaultValue={wert('ermittelterWille')} rows={4} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
<div className="space-y-2">
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="patientenverfuegungVorhanden"
defaultChecked={werte.patientenverfuegungVorhanden}
/>
<span className="text-sm font-medium">Patientenverfügung liegt vor</span>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="vorsorgevollmachtVorhanden"
defaultChecked={werte.vorsorgevollmachtVorhanden}
/>
<span className="text-sm font-medium">Vorsorgevollmacht liegt vor</span>
</label>
</div>
</CardContent>
</Card>
{/* --- Behörden -------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Behörden und Kennzeichen</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld
id="aktenzeichenBezirk"
beschriftung="Aktenzeichen Bezirk"
erklaerung="Hilfe zur Pflege nach dem 7. Kapitel SGB XII."
fehler={zustand.felder?.aktenzeichenBezirk}
>
<Input name="aktenzeichenBezirk" defaultValue={wert('aktenzeichenBezirk')} />
</Feld>
<Feld
id="aktenzeichenSozialamt"
beschriftung="Aktenzeichen Sozialamt"
fehler={zustand.felder?.aktenzeichenSozialamt}
>
<Input name="aktenzeichenSozialamt" defaultValue={wert('aktenzeichenSozialamt')} />
</Feld>
<Feld
id="rentenversicherungsnummer"
beschriftung="Rentenversicherungsnummer"
fehler={zustand.felder?.rentenversicherungsnummer}
>
<Input name="rentenversicherungsnummer" defaultValue={wert('rentenversicherungsnummer')} />
</Feld>
<Feld id="steuerId" beschriftung="Steuer-Identifikationsnummer" fehler={zustand.felder?.steuerId}>
<Input name="steuerId" inputMode="numeric" defaultValue={wert('steuerId')} />
</Feld>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Notizen</CardTitle>
</CardHeader>
<CardContent>
<Feld id="notizen" beschriftung="Freitext" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={wert('notizen')} rows={5} />
</Feld>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button asChild variant="outline" size="lg">
<Link href="/stammdaten">Abbrechen</Link>
</Button>
<Speichern />
</div>
</form>
)
}
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { SeitenKopf } from '@/components/seiten-kopf'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { oeffnen } from '@/lib/crypto/feld'
import { StammdatenFormular, type StammdatenWerte } from './formular'
export const metadata: Metadata = { title: 'Stammdaten bearbeiten' }
export const dynamic = 'force-dynamic'
/** Date → "JJJJ-MM-TT" für das Datumsfeld im Browser. */
function alsText(datum: Date | null): string | null {
return datum ? datum.toISOString().slice(0, 10) : null
}
export default async function StammdatenBearbeitenSeite() {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
// Schreibrecht wird hier geprüft, nicht erst beim Absenden.
const { betreute } = await erfordereZugang(aktive.id, 'STAMMDATEN', 'schreiben')
const offen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
const werte: StammdatenWerte = {
id: betreute.id,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsname: betreute.geburtsname,
geburtsdatum: alsText(betreute.geburtsdatum) ?? '',
geburtsort: betreute.geburtsort,
anrede: betreute.anrede,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimTelefon: betreute.heimTelefon,
heimEinzugAm: alsText(betreute.heimEinzugAm),
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
krankenkasse: betreute.krankenkasse,
krankenversichertennummer: offen.krankenversichertennummer,
pflegekasse: betreute.pflegekasse,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: alsText(betreute.pflegegradSeit),
pflegegradBescheidVom: alsText(betreute.pflegegradBescheidVom),
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: offen.diagnosen,
ermittelterWille: offen.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: alsText(betreute.bestellungAm),
bestellungBis: alsText(betreute.bestellungBis),
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
befreiterBetreuer: betreute.befreiterBetreuer,
ehrenamtlich: betreute.ehrenamtlich,
aktenzeichenBezirk: betreute.aktenzeichenBezirk,
aktenzeichenSozialamt: betreute.aktenzeichenSozialamt,
rentenversicherungsnummer: offen.rentenversicherungsnummer,
steuerId: offen.steuerId,
notizen: offen.notizen,
}
return (
<div className="space-y-6">
<SeitenKopf
titel="Stammdaten bearbeiten"
beschreibung="Was hier steht, taucht in jedem Bericht und in jedem Anschreiben wieder auf. Es lohnt sich, es einmal in Ruhe vollständig einzutragen."
pfad={[
{ titel: 'Stammdaten', href: '/stammdaten' },
{ titel: 'Bearbeiten' },
]}
/>
<StammdatenFormular werte={werte} />
</div>
)
}