feat: Jahresbericht, Vollexport, Wissensbasis und Mitleser-Freigaben

Etappe 7. Die Ausgabeschicht - alles, was diese Anwendung nach aussen
abgibt.

Der Jahresbericht nach § 1863 BGB ist ein ENTWURF zum Ueberarbeiten,
kein fertiger Text. Er zieht sich die fuenf Gliederungspunkte aus
Kontaktprotokollen, Gesundheitsereignissen und Vorgangsstatus zusammen;
was davor abgegeben wird, entscheidet der Betreuer. Das sagt auch die
Oberflaeche. Ausgabe als PDF und als DOCX, damit sich weiterschreiben
laesst.

Vollexport als ZIP-Strom mit Gegendruck, damit auch grosse Bestaende
ohne Speicherausbruch durchgehen: Originaldateien nach Jahren sortiert,
alle Tabellen als entschluesseltes JSON, die erzeugten Berichte, ein
LIESMICH und SHA-256 je Datei. Damit laesst sich die Anwendung
verlassen - kein Datensilo.

Der Export ist eine sensible Aktion: nur BETREUER und ADMIN, nur nach
frischer Anmeldung, immer mit Audit-Eintrag. Ein Mitleser kommt nicht
heran.

37 Hinweiskarten in daten/hinweise.json, Zod-geprueft und ohne
Neustart pflegbar. Jede mit Rechtsgrundlage, Link und dem stehenden
Vermerk, dass sie keine Rechtsberatung ersetzt.

Modulweise Freigaben fuer Mitleser, mit ausdruecklicher Warnung bei
Gesundheit, Finanzen und Sozialhilfe. Rollen- und Freigabeaenderungen
erfordern eine frische Anmeldung und stehen im Audit-Log.

Nebenbei geloest: Webpack loest .tsx vor .ts auf, TypeScript und Vitest
umgekehrt. Ein Modul, das beide Endungen traegt, importiert sich unter
einem der beiden Werkzeuge selbst. Deshalb heissen die Bausteine jetzt
eindeutig (jahresbericht.entwurf.ts, .inhalt.tsx, .ausgabe.ts) und
werden ueber eine gemeinsame Fassade weitergereicht.

41 Tests.

Etappe: 7
Phase: Ausgabe
This commit is contained in:
Florian Wilde
2026-08-22 06:22:44 +02:00
parent b69a12da8f
commit 5cc7f5c140
29 changed files with 5573 additions and 3076 deletions
@@ -0,0 +1,254 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { LogOut, UserPlus } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Meldung } from '@/components/berichte/meldung'
import {
benutzerAktivSetzen,
benutzerAnlegen,
rolleAendern,
sitzungenBeenden,
type Aktionszustand,
} from '@/server/actions/freigaben'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
export type BenutzerZeile = {
id: string
name: string
email: string
rolle: 'BETREUER' | 'MITLESER' | 'ADMIN'
aktiv: boolean
zweiterFaktor: boolean
letzteAnmeldung: string | null
istIchSelbst: boolean
}
const ROLLEN = ['BETREUER', 'MITLESER', 'ADMIN'] as const
function Knopf({
children,
variante = 'outline',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'ghost' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Benutzerverwaltung - nur fuer Administratoren.
*
* Jede Aenderung hier verschiebt Rechte an den Daten eines kranken
* Menschen. Deshalb verlangt jede Aktion serverseitig eine frische
* Anmeldung und schreibt einen Audit-Eintrag; die Oberflaeche sagt vorher,
* was passiert - insbesondere, dass eine Rollenaenderung alle offenen
* Anmeldungen des Betroffenen beendet.
*/
export function BenutzerVerwaltung({ benutzer }: { benutzer: BenutzerZeile[] }) {
const [anlegestand, anlegen] = useActionState<Aktionszustand, FormData>(benutzerAnlegen, {})
const [rollenstand, rolleSetzen] = useActionState<Aktionszustand, FormData>(rolleAendern, {})
const [aktivstand, aktivSetzen] = useActionState<Aktionszustand, FormData>(benutzerAktivSetzen, {})
const [sitzungsstand, beenden] = useActionState<Aktionszustand, FormData>(sitzungenBeenden, {})
const [formularOffen, setFormularOffen] = React.useState(false)
return (
<div className="space-y-6">
<Meldung
fehler={rollenstand.fehler ?? aktivstand.fehler ?? sitzungsstand.fehler}
erfolg={rollenstand.erfolg ?? aktivstand.erfolg ?? sitzungsstand.erfolg}
/>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzer</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Zustand</TableHead>
<TableHead>Zuletzt angemeldet</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benutzer.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell>
<span className="block font-medium">
{zeile.name}
{zeile.istIchSelbst && (
<span className="ml-2 text-sm font-normal text-muted-foreground">(du)</span>
)}
</span>
<span className="text-sm text-muted-foreground">{zeile.email}</span>
</TableCell>
<TableCell>
<form action={rolleSetzen} className="flex items-center gap-2">
<input type="hidden" name="userId" value={zeile.id} />
<label className="sr-only" htmlFor={`rolle-${zeile.id}`}>
Rolle von {zeile.name}
</label>
<Select
id={`rolle-${zeile.id}`}
name="rolle"
defaultValue={zeile.rolle}
className="h-9 w-40 text-sm"
>
{ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_BESCHRIFTUNG[rolle]}
</option>
))}
</Select>
<Knopf variante="ghost" laeuftText="…">
Ändern
</Knopf>
</form>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{zeile.aktiv ? (
<Badge variant="gruen">aktiv</Badge>
) : (
<Badge variant="rot">deaktiviert</Badge>
)}
{zeile.zweiterFaktor ? (
<Badge variant="outline">2FA eingerichtet</Badge>
) : (
<Badge variant="gelb">2FA fehlt noch</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{zeile.letzteAnmeldung ? formatDatumZeit(zeile.letzteAnmeldung) : 'noch nie'}
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-2">
<form action={beenden}>
<input type="hidden" name="userId" value={zeile.id} />
<Knopf variante="ghost" laeuftText="…">
<LogOut aria-hidden="true" />
Abmelden
</Knopf>
</form>
{!zeile.istIchSelbst && (
<form action={aktivSetzen}>
<input type="hidden" name="userId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.aktiv ? 'nein' : 'ja'} />
<Knopf
variante={zeile.aktiv ? 'destructive' : 'outline'}
laeuftText="…"
>
{zeile.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</Knopf>
</form>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="text-sm text-muted-foreground">
„Abmelden“ beendet alle Geräte dieses Benutzers sofort – nützlich bei einem verlorenen
Handy. Eine Rollenänderung tut das automatisch mit, damit niemand seine alten Rechte
behält, bis sein Token abläuft.
</p>
<div className="rounded-md border p-4">
<Button
type="button"
variant={formularOffen ? 'ghost' : 'default'}
onClick={() => setFormularOffen((alt) => !alt)}
aria-expanded={formularOffen}
>
<UserPlus aria-hidden="true" />
{formularOffen ? 'Abbrechen' : 'Benutzer anlegen'}
</Button>
{formularOffen && (
<form action={anlegen} className="mt-4 space-y-4">
<Meldung fehler={anlegestand.fehler} erfolg={anlegestand.erfolg} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="name"
beschriftung="Name"
fehler={anlegestand.felder?.name}
pflicht
>
<Input name="name" autoComplete="off" required maxLength={120} />
</Feld>
<Feld
id="email"
beschriftung="E-Mail"
erklaerung="Damit meldet sich der Benutzer an."
fehler={anlegestand.felder?.email}
pflicht
>
<Input type="email" name="email" autoComplete="off" required maxLength={200} />
</Feld>
<Feld
id="rolle"
beschriftung="Rolle"
erklaerung={ROLLEN_ERKLAERUNG.MITLESER}
fehler={anlegestand.felder?.rolle}
pflicht
>
<Select name="rolle" defaultValue="MITLESER">
{ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_BESCHRIFTUNG[rolle]} – {ROLLEN_ERKLAERUNG[rolle]}
</option>
))}
</Select>
</Feld>
<Feld
id="passwort"
beschriftung="Erstes Passwort"
erklaerung="Mindestens 12 Zeichen. Ein Satz aus vier Wörtern ist leichter zu merken und sicherer als „Sommer2026!“."
fehler={anlegestand.felder?.passwort}
pflicht
>
<Input type="password" name="passwort" autoComplete="new-password" required />
</Feld>
</div>
<p className="text-sm text-muted-foreground">
Der zweite Faktor wird beim ersten Anmelden eingerichtet. Ohne ihn kommt niemand an
Fachdaten – auch der neue Benutzer nicht.
</p>
<Knopf variante="default" laeuftText="Wird angelegt …">
Benutzer anlegen
</Knopf>
</form>
)}
</div>
</div>
)
}
@@ -0,0 +1,208 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { ShieldAlert, Trash2 } from 'lucide-react'
import type { Modul, Rolle } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Meldung } from '@/components/berichte/meldung'
import {
ALLE_MODULE,
BESONDERS_SENSIBEL,
MODUL_BESCHRIFTUNG,
ROLLEN_BESCHRIFTUNG,
ROLLEN_ERKLAERUNG,
} from '@/lib/auth/rollen'
import {
freigabenSpeichern,
zugangEntziehen,
type Aktionszustand,
} from '@/server/actions/freigaben'
export type ZugangsZeile = {
userId: string
name: string
email: string
rolle: Rolle
freigegebeneModule: Modul[]
hatZugang: boolean
istIchSelbst: boolean
}
const ROLLEN: Rolle[] = ['BETREUER', 'MITLESER']
function Knopf({
children,
variante = 'default',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'ghost' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Modulweise Freigabe fuer einen Benutzer und eine betreute Person.
*
* Der wichtigste Teil dieser Komponente ist die Warnung: Gesundheit,
* Finanzen und Sozialhilfe enthalten Daten, die niemanden etwas angehen -
* Diagnosen, Kontostaende, Bedarfslagen. Wer sie an ein Geschwister
* freigibt, soll das bewusst tun und nicht, weil ein Kaestchen
* unauffaellig zwischen zehn anderen stand.
*/
export function FreigabeFormular({
betreuteId,
betreuteName,
zeile,
}: {
betreuteId: string
betreuteName: string
zeile: ZugangsZeile
}) {
const [zustand, speichern] = useActionState<Aktionszustand, FormData>(freigabenSpeichern, {})
const [entzugsstand, entziehen] = useActionState<Aktionszustand, FormData>(zugangEntziehen, {})
const [rolle, setRolle] = React.useState<Rolle>(zeile.rolle)
const [module, setModule] = React.useState<Modul[]>(zeile.freigegebeneModule)
const umschalten = (modul: Modul) =>
setModule((alt) => (alt.includes(modul) ? alt.filter((m) => m !== modul) : [...alt, modul]))
const sensibelGewaehlt = module.filter((m) => BESONDERS_SENSIBEL.includes(m))
const istMitleser = rolle === 'MITLESER'
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold">{zeile.name}</h3>
<p className="text-sm text-muted-foreground">{zeile.email}</p>
</div>
{zeile.hatZugang && !zeile.istIchSelbst && (
<form
action={entziehen}
onSubmit={(ereignis) => {
if (
!window.confirm(
`${zeile.name} den Zugriff auf ${betreuteName} vollständig entziehen?`,
)
) {
ereignis.preventDefault()
}
}}
>
<input type="hidden" name="userId" value={zeile.userId} />
<input type="hidden" name="betreuteId" value={betreuteId} />
<Knopf variante="ghost" laeuftText="…">
<Trash2 aria-hidden="true" />
Zugang entziehen
</Knopf>
</form>
)}
</div>
<Meldung
fehler={zustand.fehler ?? entzugsstand.fehler}
erfolg={zustand.erfolg ?? entzugsstand.erfolg}
warnung={zustand.warnung}
/>
<form action={speichern} className="space-y-4">
<input type="hidden" name="userId" value={zeile.userId} />
<input type="hidden" name="betreuteId" value={betreuteId} />
<Feld
id={`rolle-${zeile.userId}`}
beschriftung={`Rolle für ${betreuteName}`}
erklaerung={ROLLEN_ERKLAERUNG[rolle]}
fehler={zustand.felder?.rolle}
pflicht
>
<Select
name="rolle"
value={rolle}
onChange={(ereignis) => setRolle(ereignis.target.value as Rolle)}
>
{ROLLEN.map((wert) => (
<option key={wert} value={wert}>
{ROLLEN_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
{istMitleser ? (
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">Freigegebene Bereiche</legend>
<div className="grid gap-2 sm:grid-cols-2">
{ALLE_MODULE.map((modul) => {
const sensibel = BESONDERS_SENSIBEL.includes(modul)
return (
<label
key={modul}
className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2"
>
<Checkbox
name="freigegebeneModule"
value={modul}
checked={module.includes(modul)}
onChange={() => umschalten(modul)}
/>
<span className="min-w-0">
<span className="block">{MODUL_BESCHRIFTUNG[modul]}</span>
{sensibel && (
<span className="flex items-center gap-1 text-sm font-medium text-ampel-orange">
<ShieldAlert className="size-4" aria-hidden="true" />
besonders sensibel
</span>
)}
</span>
</label>
)
})}
</div>
{sensibelGewaehlt.length > 0 && (
<Alert variant="warnung">
<ShieldAlert aria-hidden="true" />
<AlertTitle>Das sind besonders schützenswerte Daten</AlertTitle>
<AlertDescription>
Mit {sensibelGewaehlt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} gibst du
Diagnosen, Kontostände und die Bedarfslage von {betreuteName} frei. Das ist eine
Entscheidung über die Privatsphäre einer anderen Person – frag dich, ob sie es so
wollen würde (§ 1821 BGB), und gib nur frei, was wirklich gebraucht wird.
</AlertDescription>
</Alert>
)}
<p className="text-sm text-muted-foreground">
Ein Mitleser kann in keinem Bereich etwas ändern, auch nicht im freigegebenen. Was er
nicht sieht, taucht bei ihm auch nicht in der Navigation, der Suche oder im
Papierkorb auf.
</p>
</fieldset>
) : (
<p className="text-sm text-muted-foreground">
Ein Betreuer sieht und ändert alles, was zu {betreuteName} gehört. Eine Auswahl
einzelner Bereiche gibt es dafür nicht – sie würde nur vortäuschen, dass etwas
eingeschränkt wäre.
</p>
)}
<Knopf laeuftText="Wird gespeichert …">Freigabe speichern</Knopf>
</form>
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Meldung } from '@/components/berichte/meldung'
import {
endgueltigLoeschen,
wiederherstellen,
type Aktionszustand,
} from '@/server/actions/papierkorb'
import { formatDatumZeit, relativeTage, tageBis } from '@/lib/utils'
export type PapierkorbGruppe = {
art: string
bezeichnung: string
eintraege: { id: string; bezeichnung: string; geloeschtAm: string; verfaelltAm: string }[]
}
function Knopf({
children,
variante = 'ghost',
laeuftText,
}: {
children: React.ReactNode
variante?: 'ghost' | 'outline' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Der Papierkorb.
*
* Geloescht heisst hier dreissig Tage lang "beiseitegelegt". Das ist keine
* Bequemlichkeit: In einer Betreuungsakte ist ein versehentlich geloeschter
* Bescheid ein echtes Problem, und der Nachweis gegenueber Gericht und
* Miterben haengt daran. Endgueltig entfernt wird nur einzeln, nach
* Rueckfrage und nach frischer Anmeldung.
*/
export function PapierkorbListe({
betreuteId,
gruppen,
darfSchreiben,
}: {
betreuteId: string
gruppen: PapierkorbGruppe[]
darfSchreiben: boolean
}) {
const [zurueckstand, zurueckholen] = useActionState<Aktionszustand, FormData>(
wiederherstellen,
{},
)
const [loeschstand, loeschen] = useActionState<Aktionszustand, FormData>(endgueltigLoeschen, {})
if (gruppen.length === 0) {
return (
<Leerzustand
titel="Der Papierkorb ist leer"
beschreibung="Was du löschst, landet hier und bleibt 30 Tage wiederherstellbar. Danach wird es endgültig entfernt."
icon={Trash2}
/>
)
}
return (
<div className="space-y-6">
<Meldung
fehler={zurueckstand.fehler ?? loeschstand.fehler}
erfolg={zurueckstand.erfolg ?? loeschstand.erfolg}
/>
{gruppen.map((gruppe) => (
<section key={gruppe.art} className="space-y-2">
<h2 className="text-base font-semibold">
{gruppe.bezeichnung}{' '}
<span className="font-normal text-muted-foreground">({gruppe.eintraege.length})</span>
</h2>
<ul className="divide-y rounded-md border">
{gruppe.eintraege.map((eintrag) => {
const restTage = tageBis(eintrag.verfaelltAm)
return (
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 p-3">
<span className="min-w-0 flex-1">
<span className="block font-medium">{eintrag.bezeichnung}</span>
<span className="text-sm text-muted-foreground">
gelöscht am {formatDatumZeit(eintrag.geloeschtAm)} · wird endgültig entfernt{' '}
{relativeTage(restTage)}
</span>
</span>
{darfSchreiben && (
<div className="flex flex-wrap gap-2">
<form action={zurueckholen}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="entitaet" value={gruppe.art} />
<input type="hidden" name="entitaetId" value={eintrag.id} />
<Knopf variante="outline" laeuftText="…">
<RotateCcw aria-hidden="true" />
Wiederherstellen
</Knopf>
</form>
<form
action={loeschen}
onSubmit={(ereignis) => {
if (
!window.confirm(
`„${eintrag.bezeichnung}“ endgültig löschen? Das lässt sich nicht ` +
`rückgängig machen. Eine zugehörige Datei wird mit entfernt.`,
)
) {
ereignis.preventDefault()
}
}}
>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="entitaet" value={gruppe.art} />
<input type="hidden" name="entitaetId" value={eintrag.id} />
<Knopf variante="destructive" laeuftText="…">
<Trash2 aria-hidden="true" />
Endgültig löschen
</Knopf>
</form>
</div>
)}
</li>
)
})}
</ul>
</section>
))}
</div>
)
}