diff --git a/docs/schema-wuensche/berichte.md b/docs/schema-wuensche/berichte.md
new file mode 100644
index 0000000..07b3dc2
--- /dev/null
+++ b/docs/schema-wuensche/berichte.md
@@ -0,0 +1,143 @@
+# Schema-Wünsche aus der Ausgabeschicht (Etappe 7)
+
+`prisma/schema.prisma` wurde nicht angefasst. Was beim Bauen der Berichte,
+des Exports und der Freigaben gefehlt hat, steht hier – mit Begründung und
+mit dem Behelf, der jetzt an seiner Stelle steht.
+
+---
+
+## 1. Mehrere Fassungen je Bericht
+
+**Jetzt:** `Bericht` trägt genau ein `objektSchluessel` / `dateiname` /
+`pruefsumme`. Wird das PDF nach einer Rückfrage des Gerichts neu erzeugt,
+überschreibt es die Angaben der Fassung, die bereits vorgelegt wurde.
+
+**Warum das stört:** Die Leitfrage der Tabelle ist „was habe ich wann in
+welcher Fassung abgegeben?“. Genau die lässt sich nach dem zweiten
+Erzeugen nicht mehr beantworten. Fragt der Rechtspfleger in zwei Jahren
+nach, welcher Text ihm vorlag, steht der neue da.
+
+**Wunsch:**
+
+```prisma
+model Berichtsfassung {
+ id String @id @default(cuid())
+ berichtId String
+ bericht Bericht @relation(fields: [berichtId], references: [id], onDelete: Cascade)
+ nummer Int // 1, 2, 3 …
+ format String // "pdf" | "docx"
+ objektSchluessel String
+ dateiname String
+ pruefsumme String
+ erstelltAm DateTime @default(now())
+ abgegebenAm DateTime? @db.Date
+ abgegebenAn String?
+
+ @@unique([berichtId, nummer, format])
+}
+```
+
+**Behelf bis dahin:** Die PDF-Fassung wird am `Bericht` vermerkt, die
+DOCX-Arbeitsfassung bewusst *nicht* – sonst verdrängte sie die abgegebene
+Fassung. Die Word-Datei liegt im Objektspeicher und taucht im Vollexport
+auf, ist aber über die Oberfläche nur bis zum nächsten Erzeugen erreichbar.
+
+---
+
+## 2. Zusammenhang zwischen Unterlagenpaket und Nachforderung
+
+**Jetzt:** Beim Erzeugen eines Pakets werden die beigefügten
+`Nachforderung`-Zeilen auf `UEBERSANDT` gesetzt und bekommen ein
+`uebersandtAm`. In welchem Paket sie mitgegangen sind, steht nirgends.
+
+**Warum das stört:** Der Träger fragt regelmäßig nach einer einzelnen
+Position („zu Nr. 7 fehlt uns noch etwas“). Ohne Rückbezug muss man raten,
+welches Anschreiben Nr. 7 enthielt.
+
+**Wunsch:** `Nachforderung.uebersandtMitBerichtId String?` mit Relation auf
+`Bericht`.
+
+**Behelf:** Das Anschreiben nennt die laufende Nummer, und der
+`Bericht`-Datensatz trägt Datum und Positionsanzahl im Titel.
+
+---
+
+## 3. Betreuungsjahr am Bericht
+
+**Wunsch:** `Bericht.betreuungsjahr Int?` – die laufende Nummer des
+Betreuungsjahres, aus dem der Jahresbericht stammt.
+
+**Warum:** Das Betreuungsjahr läuft ab dem Tag der Bestellung. Zeiträume
+allein sind für die Dublettenprüfung unhandlich, sobald jemand den
+Vorschlag im Formular um einen Tag verschiebt.
+
+**Behelf:** Geprüft wird auf `art + zeitraumVon + zeitraumBis`. Ein um
+einen Tag verschobener Zeitraum erzeugt deshalb einen zweiten Entwurf.
+
+---
+
+## 4. Index für die Dublettenprüfung
+
+**Wunsch:** `@@index([betreuteId, art, zeitraumVon, zeitraumBis])` auf
+`Bericht`. Vorhanden ist nur `@@index([betreuteId, art])`.
+
+---
+
+## 5. Aufbewahrungsfrist am Papierkorb
+
+**Wunsch:** `geloeschtVonId String?` auf allen Modellen mit `geloeschtAm`.
+
+**Warum:** Das Audit-Log weiß, wer gelöscht hat, aber die Papierkorbliste
+müsste dafür je Zeile einen Log-Eintrag suchen. Für die Frage „wer hat das
+weggeräumt?“ direkt an der Zeile wäre ein Feld billiger.
+
+---
+
+# Entscheidungen, die keine Schemaänderung brauchen
+
+## Zwei gleichnamige Dateien: `jahresbericht.ts` und `jahresbericht.tsx`
+
+Der Bauplan verlangt beide. Beide nebeneinander sind aber eine Falle: Beim
+Import ohne Endung probiert Webpack `.tsx` vor `.ts`, TypeScript und Vitest
+umgekehrt. Derselbe Importpfad wäre je nach Werkzeug eine andere Datei –
+und ein `export * from './jahresbericht'` innerhalb der `.tsx` würde unter
+Webpack die Datei sich selbst importieren.
+
+Gelöst über eindeutige Namen für die Bausteine:
+
+| Datei | Inhalt |
+|---|---|
+| `jahresbericht.entwurf.ts` | Textbausteine, rein, ohne Datenbank – das, was getestet wird |
+| `jahresbericht.inhalt.tsx` | Satz für das PDF (React) |
+| `jahresbericht.ausgabe.ts` | Daten laden, PDF erzeugen |
+| `jahresbericht.ts` | reicht alle drei weiter |
+| `jahresbericht.tsx` | reicht alle drei weiter (identisch) |
+
+Damit bekommt jeder Aufrufer von `@/lib/berichte/jahresbericht` dieselbe
+Schnittstelle, gleich welches Werkzeug auflöst. Wer nur die Textbausteine
+braucht – die Tests, die DOCX-Ausgabe – importiert direkt
+`@/lib/berichte/jahresbericht.entwurf` und spart sich Datenbank und
+PDF-Bibliothek.
+
+**Alternative, falls das später stört:** `allowImportingTsExtensions: true`
+in der `tsconfig.json` (mit `noEmit: true` unbedenklich, das ist bereits
+gesetzt). Dann ließen sich die Endungen ausschreiben und die beiden
+weiterreichenden Dateien entfielen.
+
+## Vollexport ohne Serverkopie
+
+Das ZIP wird als Strom ausgeliefert und **nicht** im Objektspeicher
+abgelegt. Ein `Bericht`-Datensatz der Art `VOLLEXPORT` hält nur fest, dass
+und wann exportiert wurde; `objektSchluessel` bleibt leer.
+
+Grund: Eine unverschlüsselte Vollkopie des gesamten Bestands dauerhaft auf
+dem Server liegen zu lassen, wäre die größte Angriffsfläche der ganzen
+Anwendung – und niemand bräuchte sie ein zweites Mal.
+
+## Landesrecht ohne Bundesgesetz-Link
+
+Zwei Hinweiskarten betreffen bayerisches Landesrecht bzw. den
+Rundfunkbeitragsstaatsvertrag (`landespflegegeld`,
+`rundfunkbeitragsbefreiung`). Beides steht nicht auf
+`gesetze-im-internet.de`; die Karten verlinken deshalb auf
+`gesetze-bayern.de`. Das Schema verlangt nur einen `https`-Link.
diff --git a/src/app/(app)/berichte/belegmappe/page.tsx b/src/app/(app)/berichte/belegmappe/page.tsx
new file mode 100644
index 0000000..38b7c92
--- /dev/null
+++ b/src/app/(app)/berichte/belegmappe/page.tsx
@@ -0,0 +1,71 @@
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { BelegmappeFormular } from '@/components/berichte/belegmappe-formular'
+import { berichtskontext } from '@/components/berichte/kontext'
+import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
+import { db, nichtGeloescht } from '@/lib/db'
+import { alsIsoTag, heuteAlsTag, tag } from '@/lib/fristen/datum'
+
+export const metadata = { title: 'Belegmappe' }
+
+/**
+ * Belegmappe zusammenstellen.
+ *
+ * Vorbelegt ist das vergangene Kalenderjahr - der Zeitraum, fuer den das
+ * Gericht die Belege sehen will. Wer eine Mappe zu einer einzelnen Sache
+ * braucht (weil der Bezirk nachfragt), waehlt stattdessen den Vorgang.
+ */
+export default async function BelegmappeSeite() {
+ const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
+
+ const heute = heuteAlsTag()
+ const vorjahr = heute.getUTCFullYear() - 1
+
+ const vorgaenge = await db.vorgang.findMany({
+ where: { betreuteId, ...nichtGeloescht },
+ select: { id: true, titel: true, status: true },
+ orderBy: [{ status: 'asc' }, { erstelltAm: 'desc' }],
+ take: 200,
+ })
+
+ return (
+
+
+
+
+ Belege gehören zur Rechnungslegung
+
+ Auch als befreiter Betreuer, der nur eine Vermögensübersicht schuldet, solltest du die
+ Belege sammeln und geordnet halten: Das Gericht kann sie anfordern, und am Ende der
+ Betreuung ist Schlussrechnung zu legen. Dann liegt die Beweislast bei dir.
+
+
+
+ {darfBerichteSchreiben ? (
+
({
+ id: vorgang.id,
+ titel:
+ vorgang.status === 'ERLEDIGT' ? `${vorgang.titel} (erledigt)` : vorgang.titel,
+ }))}
+ vorschlagVon={alsIsoTag(tag(vorjahr, 1, 1))}
+ vorschlagBis={alsIsoTag(tag(vorjahr, 12, 31))}
+ />
+ ) : (
+
+ Du hast nur Leserechte für die Berichte und kannst deshalb keine Belegmappe erzeugen.
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/berichte/export/datei/route.ts b/src/app/(app)/berichte/export/datei/route.ts
new file mode 100644
index 0000000..9ad88f7
--- /dev/null
+++ b/src/app/(app)/berichte/export/datei/route.ts
@@ -0,0 +1,128 @@
+import { Readable } from 'node:stream'
+import type { NextRequest } from 'next/server'
+import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung'
+import { darf } from '@/lib/auth/rollen'
+import { db, nichtGeloescht } from '@/lib/db'
+import { protokollieren } from '@/lib/audit'
+import { vollexportStroemen } from '@/lib/berichte/vollexport'
+
+/**
+ * Auslieferung des Vollexports.
+ *
+ * Warum eine eigene Route und keine Server Action? Weil das Archiv als
+ * Strom entsteht: Waehrend der Browser die ersten Bytes empfaengt, wird
+ * die naechste Datei noch gelesen. Eine Server Action kann keinen Strom
+ * zurueckgeben - sie muesste das ganze ZIP erst im Arbeitsspeicher
+ * aufbauen, und bei ein paar hundert eingescannten Bescheiden ist das der
+ * Punkt, an dem der Server umfaellt.
+ *
+ * Die Pruefung ist hier vollstaendig, nicht abgekuerzt: angemeldet,
+ * Zugriff auf genau diese betreute Person, Rolle BETREUER oder ADMIN,
+ * frische Anmeldung. Ein Mitleser kommt hier nie durch - auch nicht mit
+ * allen Modulfreigaben.
+ */
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+function abgelehnt(nachricht: string, status: number): Response {
+ return new Response(nachricht, {
+ status,
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' },
+ })
+}
+
+export async function GET(anfrage: NextRequest): Promise {
+ const benutzer = await erfordereBenutzer('/berichte/export')
+
+ const gewuenscht = anfrage.nextUrl.searchParams.get('betreute')
+ const aktive = await aktiveBetreute(benutzer)
+ const betreuteId = gewuenscht ?? aktive?.id
+ if (!betreuteId) return abgelehnt('Es ist keine betreute Person ausgewählt.', 400)
+
+ const betreute = await db.betreute.findFirst({
+ where: { id: betreuteId, ...nichtGeloescht },
+ select: { id: true, vorname: true, nachname: true },
+ })
+ if (!betreute) return abgelehnt('Diese betreute Person gibt es nicht.', 404)
+
+ const eintrag = await db.betreuungsZugang.findUnique({
+ where: { userId_betreuteId: { userId: benutzer.id, betreuteId } },
+ select: { rolle: true, freigegebeneModule: true },
+ })
+ const zugang = eintrag
+ ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
+ : benutzer.rolle === 'ADMIN'
+ ? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
+ : null
+
+ if (!darf(zugang, 'BERICHTE', 'schreiben')) {
+ return abgelehnt('Für diesen Bereich fehlt dir die Berechtigung.', 403)
+ }
+ if (zugang!.rolle !== 'BETREUER' && zugang!.rolle !== 'ADMIN') {
+ return abgelehnt('Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.', 403)
+ }
+ if (!(await reauthGueltig(benutzer.id))) {
+ return abgelehnt(
+ 'Der Export muss zuerst freigeschaltet werden. Geh zurück auf die Exportseite und ' +
+ 'bestätige deine Anmeldung erneut.',
+ 403,
+ )
+ }
+
+ const ja = (name: string) => anfrage.nextUrl.searchParams.get(name) !== 'nein'
+
+ const { dateiname, strom } = await vollexportStroemen({
+ betreuteId,
+ mitDokumenten: ja('dokumente'),
+ mitAuditLog: ja('audit'),
+ mitBerichten: ja('berichte'),
+ erstelltVon: benutzer.name,
+ })
+
+ // Der Eintrag entsteht, bevor das erste Byte fliesst: Ein abgebrochener
+ // Download aendert nichts daran, dass der Bestand das Haus verlassen hat.
+ const vermerk = await db.bericht.create({
+ data: {
+ betreuteId,
+ art: 'VOLLEXPORT',
+ titel: 'Vollexport aller Daten',
+ stichtag: new Date(),
+ dateiname,
+ erstelltVonId: benutzer.id,
+ },
+ })
+
+ await protokollieren({
+ aktion: 'EXPORTIERT',
+ entitaet: 'Betreute',
+ entitaetId: betreuteId,
+ bezeichnung: `Vollexport ${dateiname}`,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ aenderung: {
+ umfang: {
+ alt: null,
+ neu: {
+ dokumente: ja('dokumente'),
+ auditLog: ja('audit'),
+ berichte: ja('berichte'),
+ vermerk: vermerk.id,
+ },
+ },
+ },
+ })
+
+ const koerper = Readable.toWeb(strom) as ReadableStream
+
+ return new Response(koerper, {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="${dateiname}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
+ },
+ })
+}
diff --git a/src/app/(app)/berichte/export/page.tsx b/src/app/(app)/berichte/export/page.tsx
new file mode 100644
index 0000000..a1e6bc6
--- /dev/null
+++ b/src/app/(app)/berichte/export/page.tsx
@@ -0,0 +1,79 @@
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { ExportFormular } from '@/components/berichte/export-formular'
+import { berichtskontext } from '@/components/berichte/kontext'
+import { db, nichtGeloescht } from '@/lib/db'
+import { formatDatumZeit } from '@/lib/utils'
+
+export const metadata = { title: 'Vollexport' }
+
+/**
+ * Vollexport aller Daten einer betreuten Person.
+ *
+ * Die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine Daten
+ * gehoeren dir, und du kommst jederzeit ohne sie an alles heran. Zugleich
+ * die heikelste Aktion im ganzen Programm - deshalb frische Anmeldung,
+ * Rolle BETREUER oder ADMIN, und jeder Export steht im Audit-Log.
+ */
+export default async function ExportSeite() {
+ const { betreuteId, betreute, zugang } = await berichtskontext('lesen')
+
+ const darfExportieren = zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN'
+
+ const [anzahlDokumente, anzahlBerichte, letzterExport] = await Promise.all([
+ db.dokument.count({ where: { betreuteId, objektSchluessel: { not: null }, ...nichtGeloescht } }),
+ db.bericht.count({ where: { betreuteId, objektSchluessel: { not: null }, ...nichtGeloescht } }),
+ db.bericht.findFirst({
+ where: { betreuteId, art: 'VOLLEXPORT' },
+ orderBy: { erstelltAm: 'desc' },
+ include: { erstelltVon: { select: { name: true } } },
+ }),
+ ])
+
+ return (
+
+
+
+
+
+
Dateien im Archiv
+ {anzahlDokumente}
+
+
+
Erzeugte Berichte
+ {anzahlBerichte}
+
+
+
Letzter Export
+
+ {letzterExport
+ ? `${formatDatumZeit(letzterExport.erstelltAm)}${
+ letzterExport.erstelltVon ? ` · ${letzterExport.erstelltVon.name}` : ''
+ }`
+ : 'noch keiner'}
+
+
+
+
+ {darfExportieren ? (
+
+ ) : (
+
+ Für den Export fehlt dir die Berechtigung
+
+ Ein Vollexport enthält den gesamten Bestand im Klartext. Erzeugen darf ihn deshalb nur
+ der Betreuer dieser Person oder ein Administrator – ein Mitleser nie, auch nicht mit
+ allen Modulfreigaben.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx b/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx
new file mode 100644
index 0000000..b9ff684
--- /dev/null
+++ b/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx
@@ -0,0 +1,106 @@
+import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { Download } from 'lucide-react'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Button } from '@/components/ui/button'
+import { EntwurfEditor } from '@/components/berichte/entwurf-editor'
+import { berichtskontext } from '@/components/berichte/kontext'
+import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
+import { db, nichtGeloescht } from '@/lib/db'
+import { entschluesselnWennMoeglich } from '@/lib/crypto/feld'
+import { signierteUrl } from '@/lib/speicher'
+import { ERGAENZEN, offeneErgaenzungen } from '@/lib/berichte/jahresbericht.entwurf'
+import { formatDatum } from '@/lib/utils'
+
+export const metadata = { title: 'Jahresbericht bearbeiten' }
+
+/**
+ * Der Arbeitsplatz am Jahresbericht.
+ *
+ * Hier wird geschrieben. Der Entwurf steht im Textfeld, Zwischenspeichern
+ * geht jederzeit, und erst wenn der Text steht, entsteht daraus ein PDF
+ * (die abzugebende Fassung) oder eine Word-Datei (die Arbeitsfassung).
+ */
+export default async function JahresberichtBearbeitenSeite({
+ params,
+}: {
+ params: Promise<{ berichtId: string }>
+}) {
+ const { berichtId } = await params
+ const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
+
+ const bericht = await db.bericht.findFirst({
+ where: { id: berichtId, betreuteId, art: 'JAHRESBERICHT', ...nichtGeloescht },
+ })
+ if (!bericht) notFound()
+
+ const entwurf =
+ entschluesselnWennMoeglich(bericht.entwurfVerschluesselt, 'entwurfVerschluesselt') ?? ''
+
+ const downloadUrl = bericht.objektSchluessel
+ ? await signierteUrl(bericht.objektSchluessel).catch(() => null)
+ : null
+
+ return (
+
+
+
+
+ Zuletzt erzeugtes PDF
+
+
+ ) : undefined
+ }
+ />
+
+ {darfBerichteSchreiben ? (
+
+ ) : (
+
+
+ Du hast nur Leserechte für die Berichte. Der Entwurf steht hier zum Mitlesen.
+
+
+ {entwurf || 'Es ist noch kein Text hinterlegt.'}
+
+
+ )}
+
+ {bericht.abgegebenAm && (
+
+ Abgegeben am {formatDatum(bericht.abgegebenAm)}
+ {bericht.abgegebenAn ? ` an ${bericht.abgegebenAn}` : ''}. Eine Änderung am Text ändert
+ nichts an der bereits vorgelegten Fassung – erzeuge dafür ein neues PDF und vermerke die
+ Abgabe erneut in der{' '}
+
+ Übersicht
+
+ .
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/berichte/jahresbericht/page.tsx b/src/app/(app)/berichte/jahresbericht/page.tsx
new file mode 100644
index 0000000..590397e
--- /dev/null
+++ b/src/app/(app)/berichte/jahresbericht/page.tsx
@@ -0,0 +1,133 @@
+import Link from 'next/link'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { JahresberichtFormular } from '@/components/berichte/jahresbericht-formular'
+import { berichtskontext } from '@/components/berichte/kontext'
+import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
+import { db, nichtGeloescht } from '@/lib/db'
+import {
+ betreuungsjahrBeginn,
+ betreuungsjahrEnde,
+ laufendesBetreuungsjahr,
+} from '@/lib/fristen/pflichtfristen'
+import { alsIsoTag, heuteAlsTag, plusTage } from '@/lib/fristen/datum'
+import { formatDatum } from '@/lib/utils'
+
+export const metadata = { title: 'Jahresbericht' }
+
+/**
+ * Einstieg in den Jahresbericht: Zeitraum waehlen, Entwurf erzeugen.
+ *
+ * Vorbelegt ist das laufende Betreuungsjahr. Das ist der Punkt, an dem die
+ * meisten Betreuer falsch liegen: Das Betreuungsjahr laeuft ab dem Tag der
+ * Bestellung, nicht ab dem 1. Januar - und danach richtet sich auch, wann
+ * der Bericht faellig ist.
+ */
+export default async function JahresberichtSeite() {
+ const { betreute, betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
+
+ const heute = heuteAlsTag()
+ let von = heute
+ let bis = heute
+
+ if (betreute.bestellungAm) {
+ const nummer = laufendesBetreuungsjahr(betreute.bestellungAm, heute)
+ von = betreuungsjahrBeginn(betreute.bestellungAm, nummer)
+ bis = betreuungsjahrEnde(betreute.bestellungAm, nummer)
+ } else {
+ von = plusTage(heute, -365)
+ }
+
+ const vorhandene = await db.bericht.findMany({
+ where: { betreuteId, art: 'JAHRESBERICHT', ...nichtGeloescht },
+ orderBy: { zeitraumBis: 'desc' },
+ take: 20,
+ })
+
+ return (
+
+
+
+
+ Die Anwendung schreibt einen Entwurf, nicht den Bericht
+
+ Aus den erfassten Kontakten, Gesundheitsereignissen und Vorgängen entsteht ein
+ Textvorschlag mit allen fünf Punkten, die § 1863 Abs. 3 BGB verlangt. Diesen Text
+ überarbeitest du anschließend im Browser – erst danach wird daraus ein PDF. Nichts wird
+ von allein abgegeben.
+
+
+
+ {betreute.bestellungAm ? (
+
+ Bestellung am {formatDatum(betreute.bestellungAm)}. Das laufende Betreuungsjahr geht vom{' '}
+ {formatDatum(von)} bis zum {formatDatum(bis)}.
+
+ ) : (
+
+ Kein Bestellungsdatum hinterlegt
+
+ Ohne den Tag deiner Bestellung lässt sich das Betreuungsjahr nicht berechnen; der
+ Vorschlag unten ist deshalb nur das letzte Jahr ab heute. Trag das Datum in den{' '}
+
+ Stammdaten
+ {' '}
+ nach – daran hängen auch die Pflichtfristen.
+
+
+ )}
+
+ {darfBerichteSchreiben ? (
+
+ ) : (
+
+ Du hast nur Leserechte für die Berichte und kannst deshalb keinen Entwurf erzeugen.
+
+ )}
+
+ {vorhandene.length > 0 && (
+
+ Bisherige Jahresberichte
+
+ {vorhandene.map((bericht) => (
+
+
+
+ {bericht.titel}
+
+
+ {formatDatum(bericht.zeitraumVon)} – {formatDatum(bericht.zeitraumBis)}
+
+
+ {bericht.abgegebenAm ? (
+ abgegeben am {formatDatum(bericht.abgegebenAm)}
+ ) : bericht.objektSchluessel ? (
+ PDF erzeugt, noch nicht abgegeben
+ ) : (
+ Entwurf in Arbeit
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/berichte/page.tsx b/src/app/(app)/berichte/page.tsx
new file mode 100644
index 0000000..d073b2f
--- /dev/null
+++ b/src/app/(app)/berichte/page.tsx
@@ -0,0 +1,163 @@
+import Link from 'next/link'
+import {
+ Archive,
+ CalendarClock,
+ FolderArchive,
+ Paperclip,
+ ScrollText,
+} from 'lucide-react'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { BerichtsListe } from '@/components/berichte/berichts-liste'
+import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
+import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
+import { db, nichtGeloescht } from '@/lib/db'
+import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
+
+export const metadata = { title: 'Berichte' }
+
+/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
+const BERICHTSFRISTEN = [
+ 'anfangsbericht',
+ 'jahresbericht',
+ 'vermoegensverzeichnis',
+ 'vermoegensuebersicht',
+ 'rechnungslegung',
+]
+
+const WEGE = [
+ {
+ href: '/berichte/jahresbericht',
+ titel: 'Jahresbericht',
+ beschreibung:
+ 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
+ icon: ScrollText,
+ },
+ {
+ href: '/berichte/belegmappe',
+ titel: 'Belegmappe',
+ beschreibung:
+ 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
+ icon: FolderArchive,
+ },
+ {
+ href: '/berichte/unterlagenpaket',
+ titel: 'Unterlagen für den Träger',
+ beschreibung:
+ 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
+ icon: Paperclip,
+ },
+ {
+ href: '/berichte/export',
+ titel: 'Vollexport',
+ beschreibung:
+ 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
+ icon: Archive,
+ },
+]
+
+/**
+ * Die Berichtsuebersicht beantwortet zwei Fragen:
+ * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
+ *
+ * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
+ * Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
+ */
+export default async function BerichteSeite() {
+ const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
+
+ const [fristen, zeilen] = await Promise.all([
+ db.frist.findMany({
+ where: {
+ betreuteId,
+ status: 'OFFEN',
+ ...nichtGeloescht,
+ OR: BERICHTSFRISTEN.map((schluessel) => ({
+ systemSchluessel: { startsWith: schluessel },
+ })),
+ },
+ orderBy: { datum: 'asc' },
+ take: 10,
+ }),
+ berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
+ ])
+
+ return (
+
+
+
+
+ Demnächst fällig
+ {fristen.length === 0 ? (
+
+ Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
+
+ Fristen
+
+ , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
+
+ ) : (
+
+ {fristen.map((frist) => {
+ const tage = tageBis(frist.datum)
+ const variante =
+ tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
+ return (
+
+
+
+ {frist.titel}
+
+ {formatDatum(frist.datum)}
+ {frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
+
+
+ {frist.ausschlussfrist && Ausschlussfrist }
+
+ {tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {WEGE.map((weg) => {
+ const Icon = weg.icon
+ return (
+
+
+
+
+
+
+ {weg.titel}
+
+
+ {weg.beschreibung}
+
+
+
+ )
+ })}
+
+
+
+ Was ich abgegeben habe
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/berichte/unterlagenpaket/page.tsx b/src/app/(app)/berichte/unterlagenpaket/page.tsx
new file mode 100644
index 0000000..6500c5d
--- /dev/null
+++ b/src/app/(app)/berichte/unterlagenpaket/page.tsx
@@ -0,0 +1,141 @@
+import Link from 'next/link'
+import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { UnterlagenpaketFormular } from '@/components/berichte/unterlagenpaket-formular'
+import { berichtskontext } from '@/components/berichte/kontext'
+import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
+import { erfordereZugang } from '@/lib/auth/sitzung'
+import { db, nichtGeloescht } from '@/lib/db'
+
+export const metadata = { title: 'Unterlagen für den Träger' }
+
+/**
+ * Das Unterlagenpaket fuer den Sozialhilfetraeger.
+ *
+ * Ausgangspunkt ist der Nachforderungs-Tracker: Was der Bezirk angefordert
+ * hat, steht dort als eigene Zeile. Hier wird ausgewaehlt, was in dieses
+ * Paket kommt - auch das, was es nicht gibt, denn eine unbeantwortete
+ * Position gilt beim Traeger als fehlende Mitwirkung.
+ */
+export default async function UnterlagenpaketSeite({
+ searchParams,
+}: {
+ searchParams: Promise>
+}) {
+ const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
+ // Die Unterlagen stammen aus der Sozialhilfeakte - auch dort muss gelesen
+ // werden duerfen, sonst umginge diese Seite die Modulfreigabe.
+ await erfordereZugang(betreuteId, 'SOZIALHILFE', 'lesen')
+
+ const parameter = await searchParams
+ const gewaehlterAntrag = Array.isArray(parameter.antrag) ? parameter.antrag[0] : parameter.antrag
+
+ const antraege = await db.sozialhilfeAntrag.findMany({
+ where: { betreuteId, ...nichtGeloescht },
+ orderBy: { erstelltAm: 'desc' },
+ include: {
+ nachforderungen: {
+ where: nichtGeloescht,
+ orderBy: [{ reihenfolge: 'asc' }, { bezeichnung: 'asc' }],
+ include: { verknuepfungen: { select: { dokumentId: true } } },
+ },
+ },
+ })
+
+ const antrag = antraege.find((a) => a.id === gewaehlterAntrag) ?? antraege[0]
+
+ if (!antrag) {
+ return (
+
+
+
+ Zur Sozialhilfe
+
+ }
+ />
+
+ )
+ }
+
+ const nachforderungen = antrag.nachforderungen.map((eintrag) => ({
+ id: eintrag.id,
+ bezeichnung: eintrag.bezeichnung,
+ status: eintrag.status,
+ fristBis: eintrag.fristBis ? eintrag.fristBis.toISOString() : null,
+ begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden,
+ anzahlDokumente: eintrag.verknuepfungen.length,
+ }))
+
+ return (
+
+
+
+ {antraege.length > 1 && (
+
+ {antraege.map((eintrag) => (
+
+
+ {eintrag.leistungsart}
+ {eintrag.aktenzeichen ? ` · ${eintrag.aktenzeichen}` : ''}
+
+
+ ))}
+
+ )}
+
+
+ Auch fehlende Unterlagen gehören ins Paket
+
+ Zu jeder Position, die du mitschickst, aber nicht belegen kannst, erscheint im Anschreiben
+ eine Erklärung. Das ist wichtig: Eine Position, zu der der Träger nichts hört, gilt als
+ fehlende Mitwirkung – und das kann die Leistung kosten.
+
+
+
+ {nachforderungen.length === 0 ? (
+
+ Zum Nachforderungs-Tracker
+
+ }
+ />
+ ) : darfBerichteSchreiben ? (
+
+ ) : (
+
+ Du hast nur Leserechte für die Berichte und kannst deshalb kein Paket erzeugen.
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/einstellungen/benutzer/page.tsx b/src/app/(app)/einstellungen/benutzer/page.tsx
new file mode 100644
index 0000000..644128b
--- /dev/null
+++ b/src/app/(app)/einstellungen/benutzer/page.tsx
@@ -0,0 +1,64 @@
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { BenutzerVerwaltung, type BenutzerZeile } from '@/components/einstellungen/benutzer-verwaltung'
+import { erfordereBenutzer } from '@/lib/auth/sitzung'
+import { db, nichtGeloescht } from '@/lib/db'
+
+export const metadata = { title: 'Benutzer' }
+
+/**
+ * Benutzerverwaltung - Administratoren vorbehalten.
+ *
+ * Die Seite prueft die Rolle selbst; jede einzelne Aktion prueft sie
+ * serverseitig noch einmal und verlangt zusaetzlich eine frische
+ * Anmeldung. Nur im UI auszublenden waere kein Schutz.
+ */
+export default async function BenutzerSeite() {
+ const angemeldet = await erfordereBenutzer('/einstellungen/benutzer')
+
+ if (angemeldet.rolle !== 'ADMIN') {
+ return (
+
+
+
+ Dieser Bereich ist Administratoren vorbehalten
+
+ Wer Benutzer anlegt und Rollen vergibt, entscheidet darüber, wer die Daten eines
+ kranken Menschen sehen darf. Diese Entscheidung liegt bewusst an einer Stelle.
+
+
+
+ )
+ }
+
+ const benutzer = await db.user.findMany({
+ where: nichtGeloescht,
+ orderBy: [{ aktiv: 'desc' }, { name: 'asc' }],
+ })
+
+ const zeilen: BenutzerZeile[] = benutzer.map((eintrag) => ({
+ id: eintrag.id,
+ name: eintrag.name,
+ email: eintrag.email,
+ rolle: eintrag.rolle,
+ aktiv: eintrag.aktiv,
+ zweiterFaktor: Boolean(eintrag.totpAktiviertAm),
+ letzteAnmeldung: eintrag.letzteAnmeldung ? eintrag.letzteAnmeldung.toISOString() : null,
+ istIchSelbst: eintrag.id === angemeldet.id,
+ }))
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/einstellungen/freigaben/page.tsx b/src/app/(app)/einstellungen/freigaben/page.tsx
new file mode 100644
index 0000000..0f48f2d
--- /dev/null
+++ b/src/app/(app)/einstellungen/freigaben/page.tsx
@@ -0,0 +1,100 @@
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import {
+ FreigabeFormular,
+ type ZugangsZeile,
+} from '@/components/einstellungen/freigabe-formular'
+import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
+import { darfFreigabenVergeben } from '@/lib/validierung/freigaben'
+import { db, nichtGeloescht } from '@/lib/db'
+
+export const metadata = { title: 'Freigaben' }
+
+/**
+ * Wer darf welche Bereiche der Akte lesen?
+ *
+ * Die Freigabe gilt immer fuer genau eine betreute Person und genau einen
+ * Benutzer. Das ist Absicht: "Meine Schwester darf die Fristen sehen, aber
+ * nicht die Diagnosen" muss sich abbilden lassen, ohne dass daraus ein
+ * Zugriff auf eine zweite Akte wird.
+ */
+export default async function FreigabenSeite() {
+ const angemeldet = await erfordereBenutzer('/einstellungen/freigaben')
+ const gewaehlt = await aktiveBetreute(angemeldet)
+ if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.')
+
+ const { betreute, zugang } = await erfordereZugang(gewaehlt.id, 'STAMMDATEN', 'lesen')
+ const betreuteName = `${betreute.vorname} ${betreute.nachname}`
+
+ if (!darfFreigabenVergeben(zugang)) {
+ return (
+
+
+
+ Freigaben vergibt nur der Betreuer
+
+ Als Mitleser kannst du keine Zugriffe verändern – auch nicht deine eigenen. Sonst
+ könnte sich jeder Mitleser selbst mehr Rechte geben, als ihm eingeräumt wurden.
+
+
+
+ )
+ }
+
+ const [benutzer, zugaenge] = await Promise.all([
+ db.user.findMany({
+ where: { aktiv: true, ...nichtGeloescht },
+ orderBy: { name: 'asc' },
+ }),
+ db.betreuungsZugang.findMany({ where: { betreuteId: betreute.id } }),
+ ])
+
+ const zeilen: ZugangsZeile[] = benutzer.map((eintrag) => {
+ const vorhandener = zugaenge.find((z) => z.userId === eintrag.id)
+ return {
+ userId: eintrag.id,
+ name: eintrag.name,
+ email: eintrag.email,
+ rolle: vorhandener?.rolle ?? (eintrag.rolle === 'MITLESER' ? 'MITLESER' : 'BETREUER'),
+ freigegebeneModule: vorhandener?.freigegebeneModule ?? [],
+ hatZugang: Boolean(vorhandener),
+ istIchSelbst: eintrag.id === angemeldet.id,
+ }
+ })
+
+ return (
+
+
+
+
+ Freigeben heißt: über die Privatsphäre eines anderen entscheiden
+
+ Was hier freigegeben wird, betrifft nicht deine Daten, sondern die von {betreuteName}.
+ Maßstab ist ihr Wille (§ 1821 BGB), nicht die Bequemlichkeit der Familie. Im Zweifel
+ weniger freigeben – nachträglich erweitern geht jederzeit.
+
+
+
+
+ {zeilen.map((zeile) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx
new file mode 100644
index 0000000..4ff7acf
--- /dev/null
+++ b/src/app/(app)/einstellungen/page.tsx
@@ -0,0 +1,91 @@
+import Link from 'next/link'
+import { KeyRound, Trash2, Users } from 'lucide-react'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { erfordereBenutzer } from '@/lib/auth/sitzung'
+import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
+
+export const metadata = { title: 'Einstellungen' }
+
+/**
+ * Einstiegsseite der Einstellungen.
+ *
+ * Hier liegt alles, was nicht zur Akte gehoert, sondern zur Anwendung
+ * selbst: wer sie benutzen darf, wer was davon sehen darf, und was
+ * geloescht, aber noch nicht weg ist.
+ */
+export default async function EinstellungenSeite() {
+ const benutzer = await erfordereBenutzer('/einstellungen')
+ const istAdmin = benutzer.rolle === 'ADMIN'
+
+ return (
+
+
+ Angemeldet als {benutzer.name}{' '}
+ {ROLLEN_BESCHRIFTUNG[benutzer.rolle]}
+ >
+ }
+ />
+
+
+ {istAdmin && (
+
+
+
+
+
+
+ Benutzer
+
+
+
+ Anlegen, Rolle ändern, deaktivieren, alle Geräte abmelden. Nur für
+ Administratoren.
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ Freigaben
+
+
+
+ Wer darf welche Bereiche der Akte lesen? Mitleser bekommen einzelne Bereiche
+ freigegeben – schreiben dürfen sie nie.
+
+
+
+
+
+
+
+
+
+
+
+ Papierkorb
+
+
+
+ Was in den letzten 30 Tagen gelöscht wurde – wiederherstellbar, bis die Frist
+ abgelaufen ist.
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/einstellungen/papierkorb/page.tsx b/src/app/(app)/einstellungen/papierkorb/page.tsx
new file mode 100644
index 0000000..95d03c0
--- /dev/null
+++ b/src/app/(app)/einstellungen/papierkorb/page.tsx
@@ -0,0 +1,69 @@
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import {
+ PapierkorbListe,
+ type PapierkorbGruppe,
+} from '@/components/einstellungen/papierkorb-liste'
+import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
+import { papierkorbLaden } from '@/server/actions/papierkorb'
+import { PAPIERKORB_TAGE } from '@/lib/db'
+
+export const metadata = { title: 'Papierkorb' }
+
+/**
+ * Was in den letzten 30 Tagen geloescht wurde.
+ *
+ * Ein Mitleser sieht hier nur, was ihm auch sonst freigegeben ist - der
+ * Papierkorb ist keine Hintertuer an der Modulfreigabe vorbei.
+ */
+export default async function PapierkorbSeite() {
+ const angemeldet = await erfordereBenutzer('/einstellungen/papierkorb')
+ const gewaehlt = await aktiveBetreute(angemeldet)
+ if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.')
+
+ const { betreute, zugang } = await erfordereZugang(gewaehlt.id, 'STAMMDATEN', 'lesen')
+ const darfSchreiben = zugang.rolle !== 'MITLESER'
+
+ const roh = await papierkorbLaden(betreute.id)
+
+ const gruppen: PapierkorbGruppe[] = roh.map((gruppe) => ({
+ art: gruppe.art,
+ bezeichnung: gruppe.bezeichnung,
+ eintraege: gruppe.eintraege.map((eintrag) => {
+ const verfaellt = new Date(eintrag.geloeschtAm)
+ verfaellt.setDate(verfaellt.getDate() + PAPIERKORB_TAGE)
+ return {
+ id: eintrag.id,
+ bezeichnung: eintrag.bezeichnung,
+ geloeschtAm: eintrag.geloeschtAm.toISOString(),
+ verfaelltAm: verfaellt.toISOString(),
+ }
+ }),
+ }))
+
+ return (
+
+
+
+
+ Endgültig löschen ist endgültig
+
+ Beim endgültigen Löschen verschwindet auch die zugehörige Datei aus dem Speicher. Das
+ verlangt eine erneute Bestätigung deiner Anmeldung. Im Audit-Log bleibt festgehalten,
+ dass es den Datensatz gab und wer ihn entfernt hat – das ist dein Nachweis gegenüber
+ Gericht und möglichen Miterben.
+
+
+
+
+
+ )
+}
diff --git a/src/components/berichte/berichts-liste.tsx b/src/components/berichte/berichts-liste.tsx
new file mode 100644
index 0000000..2d65dca
--- /dev/null
+++ b/src/components/berichte/berichts-liste.tsx
@@ -0,0 +1,226 @@
+'use client'
+
+import * as React from 'react'
+import Link from 'next/link'
+import { useActionState } from 'react'
+import { useFormStatus } from 'react-dom'
+import { Download, Pencil, Trash2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Input } from '@/components/ui/input'
+import { Feld } from '@/components/feld'
+import { Leerzustand } from '@/components/seiten-kopf'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Meldung } from '@/components/berichte/meldung'
+import { abgabeVermerken, berichtLoeschen, type Aktionszustand } from '@/server/actions/berichte'
+import { formatDatum, formatDatumZeit } from '@/lib/utils'
+
+export type BerichtsZeile = {
+ id: string
+ art: string
+ artBeschriftung: string
+ titel: string
+ zeitraum: string | null
+ erstelltAm: string
+ erstelltVon: string | null
+ dateiname: string | null
+ /** Kurzlebige, signierte URL - serverseitig erzeugt. */
+ downloadUrl: string | null
+ abgegebenAm: string | null
+ abgegebenAn: string | null
+ bearbeitbar: boolean
+}
+
+function KleinerKnopf({
+ children,
+ variante = 'ghost',
+}: {
+ children: React.ReactNode
+ variante?: 'ghost' | 'outline' | 'destructive'
+}) {
+ const { pending } = useFormStatus()
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Was habe ich wann in welcher Fassung abgegeben?
+ *
+ * Diese Tabelle ist die Antwort darauf. Sie ist kein Archiv um des
+ * Archivierens willen: Wenn das Gericht in zwei Jahren nachfragt, welche
+ * Fassung des Jahresberichts vorgelegen hat, steht sie hier - mit Datum,
+ * Empfaenger und der abgelegten Datei.
+ */
+export function BerichtsListe({
+ zeilen,
+ darfSchreiben,
+}: {
+ zeilen: BerichtsZeile[]
+ darfSchreiben: boolean
+}) {
+ const [loeschstand, loeschen] = useActionState(berichtLoeschen, {})
+ const [abgabestand, abgeben] = useActionState(abgabeVermerken, {})
+ const [offeneAbgabe, setOffeneAbgabe] = React.useState(null)
+
+ if (zeilen.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Bericht
+ Zeitraum
+ Erzeugt
+ Abgegeben
+ Aktionen
+
+
+
+ {zeilen.map((zeile) => (
+
+
+
+ {zeile.titel}
+
+ {zeile.artBeschriftung}
+
+
+ {zeile.zeitraum ?? '–'}
+
+ {formatDatumZeit(zeile.erstelltAm)}
+ {zeile.erstelltVon && (
+ {zeile.erstelltVon}
+ )}
+
+
+ {zeile.abgegebenAm ? (
+ <>
+ {formatDatum(zeile.abgegebenAm)}
+ {zeile.abgegebenAn && (
+
+ an {zeile.abgegebenAn}
+
+ )}
+ >
+ ) : (
+ noch nicht vermerkt
+ )}
+
+
+
+ {zeile.bearbeitbar && (
+
+
+
+ Bearbeiten
+
+
+ )}
+ {zeile.downloadUrl && (
+
+
+
+ Datei
+
+
+ )}
+ {darfSchreiben && !zeile.abgegebenAm && (
+
+ setOffeneAbgabe((alt) => (alt === zeile.id ? null : zeile.id))
+ }
+ aria-expanded={offeneAbgabe === zeile.id}
+ >
+ Abgabe vermerken
+
+ )}
+ {darfSchreiben && (
+
+ )}
+
+
+
+
+ {offeneAbgabe === zeile.id && (
+
+
+
+
+
+ )}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/berichte/export-formular.tsx b/src/components/berichte/export-formular.tsx
new file mode 100644
index 0000000..2bad2e8
--- /dev/null
+++ b/src/components/berichte/export-formular.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import * as React from 'react'
+import { useActionState } from 'react'
+import { useFormStatus } from 'react-dom'
+import { Download, ShieldCheck } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Meldung } from '@/components/berichte/meldung'
+import { exportVorbereiten, type Aktionszustand } from '@/server/actions/berichte'
+
+function Bestaetigen() {
+ const { pending } = useFormStatus()
+ return (
+
+
+ {pending ? 'Wird geprüft …' : 'Export freischalten'}
+
+ )
+}
+
+/**
+ * Vollexport anstossen.
+ *
+ * Zwei Schritte, mit Absicht: Erst wird die Anmeldung bestaetigt (die
+ * Aktion leitet zur Bestaetigungsseite um, wenn sie zu lange her ist),
+ * danach laedt der Browser das ZIP von einer eigenen Route. Ein Strom
+ * laesst sich aus einer Server Action nicht zurueckgeben, und ein Bestand
+ * aus hunderten Bescheiden soll nicht erst komplett in den Arbeitsspeicher.
+ */
+export function ExportFormular({ betreuteId }: { betreuteId: string }) {
+ const [zustand, aktion] = useActionState(exportVorbereiten, {})
+ const [mitDokumenten, setMitDokumenten] = React.useState(true)
+ const [mitAuditLog, setMitAuditLog] = React.useState(true)
+ const [mitBerichten, setMitBerichten] = React.useState(true)
+
+ const abfrage = new URLSearchParams({
+ betreute: betreuteId,
+ dokumente: mitDokumenten ? 'ja' : 'nein',
+ audit: mitAuditLog ? 'ja' : 'nein',
+ berichte: mitBerichten ? 'ja' : 'nein',
+ })
+
+ return (
+
+
+ Der Export enthält alles – unverschlüsselt
+
+ Im Archiv stehen Diagnosen, Kontostände, Notizen und Bescheide im Klartext. Es ist die
+ Sicherung, mit der du auch ohne diese Anwendung weiterarbeiten kannst – und zugleich die
+ Datei, die niemals auf einem fremden Rechner oder in einer Cloud liegen sollte. Bewahre
+ sie so auf wie den Aktenordner selbst.
+
+
+
+
+ Was soll hinein?
+
+
+ setMitDokumenten(ereignis.target.checked)}
+ />
+
+ Alle Originaldateien
+
+ Bescheide, Kontoauszüge, Fotos – nach Jahren sortiert unter dokumente/.
+
+
+
+
+
+ setMitBerichten(ereignis.target.checked)}
+ />
+
+ Erzeugte Berichte
+
+ Jahresberichte, Belegmappen, Unterlagenpakete als PDF.
+
+
+
+
+
+ setMitAuditLog(ereignis.target.checked)}
+ />
+
+ Audit-Log
+
+ Wer hat wann was geändert. Dein Nachweis gegenüber Gericht und Miterben – lass es
+ drin, wenn du keinen Grund dagegen hast.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/berichte/kontext.ts b/src/components/berichte/kontext.ts
new file mode 100644
index 0000000..adfc16e
--- /dev/null
+++ b/src/components/berichte/kontext.ts
@@ -0,0 +1,79 @@
+import 'server-only'
+import type { BerichtArt } from '@prisma/client'
+import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
+import { signierteUrl } from '@/lib/speicher'
+import { db, nichtGeloescht } from '@/lib/db'
+import { formatDatum } from '@/lib/utils'
+import type { BerichtsZeile } from '@/components/berichte/berichts-liste'
+
+/**
+ * Der gemeinsame Vorspann aller Berichtsseiten.
+ *
+ * Wie ueberall gilt: Die aktive betreute Person kommt aus dem Cookie, ob
+ * sie angezeigt werden darf, entscheidet erfordereZugang() gegen die
+ * Datenbank. Ein manipuliertes Cookie kaeme hoechstens bis hierher.
+ */
+export async function berichtskontext(art: 'lesen' | 'schreiben' = 'lesen') {
+ const benutzer = await erfordereBenutzer('/berichte')
+ const gewaehlt = await aktiveBetreute(benutzer)
+ if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.')
+
+ const kontext = await erfordereZugang(gewaehlt.id, 'BERICHTE', art)
+ return {
+ ...kontext,
+ betreuteId: gewaehlt.id,
+ darfBerichteSchreiben: kontext.darfSchreiben('BERICHTE'),
+ }
+}
+
+export const BERICHTSART_BESCHRIFTUNG: Record = {
+ VERMOEGENSVERZEICHNIS: 'Vermögensverzeichnis (§ 1835 BGB)',
+ JAHRESBERICHT: 'Jahresbericht (§ 1863 BGB)',
+ VERMOEGENSUEBERSICHT: 'Vermögensübersicht (§ 1859 BGB)',
+ RECHNUNGSLEGUNG: 'Rechnungslegung (§ 1865 BGB)',
+ BELEGMAPPE: 'Belegmappe',
+ UNTERLAGENPAKET_BEZIRK: 'Unterlagenpaket für den Träger',
+ VOLLEXPORT: 'Vollexport',
+}
+
+/**
+ * Laedt die protokollierten Berichte und erzeugt zu jeder abgelegten Datei
+ * eine kurzlebige, signierte Adresse. Die Adresse wird bei jedem
+ * Seitenaufbau neu erzeugt und laeuft nach fuenf Minuten ab - ein
+ * versehentlich weitergegebener Link ist dann wertlos.
+ */
+export async function berichtsZeilenLaden(
+ betreuteId: string,
+ darfSchreiben: boolean,
+): Promise {
+ const berichte = await db.bericht.findMany({
+ where: { betreuteId, ...nichtGeloescht },
+ include: { erstelltVon: { select: { name: true } } },
+ orderBy: { erstelltAm: 'desc' },
+ take: 200,
+ })
+
+ return Promise.all(
+ berichte.map(async (bericht) => ({
+ id: bericht.id,
+ art: bericht.art,
+ artBeschriftung: BERICHTSART_BESCHRIFTUNG[bericht.art],
+ titel: bericht.titel,
+ zeitraum:
+ bericht.zeitraumVon || bericht.zeitraumBis
+ ? `${formatDatum(bericht.zeitraumVon)} – ${formatDatum(bericht.zeitraumBis)}`
+ : bericht.stichtag
+ ? `Stand ${formatDatum(bericht.stichtag)}`
+ : null,
+ erstelltAm: bericht.erstelltAm.toISOString(),
+ erstelltVon: bericht.erstelltVon?.name ?? null,
+ dateiname: bericht.dateiname,
+ downloadUrl: bericht.objektSchluessel
+ ? await signierteUrl(bericht.objektSchluessel).catch(() => null)
+ : null,
+ abgegebenAm: bericht.abgegebenAm ? bericht.abgegebenAm.toISOString() : null,
+ abgegebenAn: bericht.abgegebenAn,
+ bearbeitbar: darfSchreiben && bericht.art === 'JAHRESBERICHT',
+ })),
+ )
+}
diff --git a/src/components/berichte/unterlagenpaket-formular.tsx b/src/components/berichte/unterlagenpaket-formular.tsx
new file mode 100644
index 0000000..3f3a1d2
--- /dev/null
+++ b/src/components/berichte/unterlagenpaket-formular.tsx
@@ -0,0 +1,137 @@
+'use client'
+
+import * as React from 'react'
+import { useActionState } from 'react'
+import { useFormStatus } from 'react-dom'
+import { Paperclip } from 'lucide-react'
+import { Feld } from '@/components/feld'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Meldung } from '@/components/berichte/meldung'
+import { unterlagenpaketAnlegen, type Aktionszustand } from '@/server/actions/berichte'
+import { formatDatum } from '@/lib/utils'
+
+export type NachforderungsAuswahl = {
+ id: string
+ bezeichnung: string
+ status: string
+ fristBis: string | null
+ begruendungNichtVorhanden: string | null
+ anzahlDokumente: number
+}
+
+function Absenden({ anzahl }: { anzahl: number }) {
+ const { pending } = useFormStatus()
+ return (
+
+
+ {pending
+ ? 'Paket wird gebaut …'
+ : anzahl === 0
+ ? 'Bitte mindestens eine Unterlage wählen'
+ : `Paket mit ${anzahl} Positionen erzeugen`}
+
+ )
+}
+
+/**
+ * Auswahl der Unterlagen, die an den Sozialhilfetraeger gehen.
+ *
+ * Es koennen auch Positionen mitgeschickt werden, zu denen es kein
+ * Dokument gibt - im Anschreiben erscheinen sie dann mit der hinterlegten
+ * Begruendung. Das ist wichtig: Eine unbeantwortete Position gilt beim
+ * Traeger als fehlende Mitwirkung, eine begruendete nicht.
+ */
+export function UnterlagenpaketFormular({
+ betreuteId,
+ antragId,
+ traeger,
+ nachforderungen,
+}: {
+ betreuteId: string
+ antragId: string
+ traeger: string
+ nachforderungen: NachforderungsAuswahl[]
+}) {
+ const [zustand, aktion] = useActionState(unterlagenpaketAnlegen, {})
+ const [gewaehlt, setGewaehlt] = React.useState(
+ nachforderungen.filter((n) => n.anzahlDokumente > 0).map((n) => n.id),
+ )
+
+ const umschalten = (id: string) =>
+ setGewaehlt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]))
+
+ return (
+
+ )
+}
diff --git a/src/components/einstellungen/benutzer-verwaltung.tsx b/src/components/einstellungen/benutzer-verwaltung.tsx
new file mode 100644
index 0000000..8984e67
--- /dev/null
+++ b/src/components/einstellungen/benutzer-verwaltung.tsx
@@ -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 (
+
+ {pending ? laeuftText : children}
+
+ )
+}
+
+/**
+ * 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(benutzerAnlegen, {})
+ const [rollenstand, rolleSetzen] = useActionState(rolleAendern, {})
+ const [aktivstand, aktivSetzen] = useActionState(benutzerAktivSetzen, {})
+ const [sitzungsstand, beenden] = useActionState(sitzungenBeenden, {})
+ const [formularOffen, setFormularOffen] = React.useState(false)
+
+ return (
+
+
+
+
+
+
+
+ Benutzer
+ Rolle
+ Zustand
+ Zuletzt angemeldet
+ Aktionen
+
+
+
+ {benutzer.map((zeile) => (
+
+
+
+ {zeile.name}
+ {zeile.istIchSelbst && (
+ (du)
+ )}
+
+ {zeile.email}
+
+
+
+
+
+ Rolle von {zeile.name}
+
+
+ {ROLLEN.map((rolle) => (
+
+ {ROLLEN_BESCHRIFTUNG[rolle]}
+
+ ))}
+
+
+ Ändern
+
+
+
+
+
+ {zeile.aktiv ? (
+ aktiv
+ ) : (
+ deaktiviert
+ )}
+ {zeile.zweiterFaktor ? (
+ 2FA eingerichtet
+ ) : (
+ 2FA fehlt noch
+ )}
+
+
+
+ {zeile.letzteAnmeldung ? formatDatumZeit(zeile.letzteAnmeldung) : 'noch nie'}
+
+
+
+
+
+
+
+ Abmelden
+
+
+ {!zeile.istIchSelbst && (
+
+
+
+
+ {zeile.aktiv ? 'Deaktivieren' : 'Aktivieren'}
+
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+ „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.
+
+
+
+
+ )
+}
diff --git a/src/components/einstellungen/freigabe-formular.tsx b/src/components/einstellungen/freigabe-formular.tsx
new file mode 100644
index 0000000..8b4b9c2
--- /dev/null
+++ b/src/components/einstellungen/freigabe-formular.tsx
@@ -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 (
+
+ {pending ? laeuftText : children}
+
+ )
+}
+
+/**
+ * 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(freigabenSpeichern, {})
+ const [entzugsstand, entziehen] = useActionState(zugangEntziehen, {})
+
+ const [rolle, setRolle] = React.useState(zeile.rolle)
+ const [module, setModule] = React.useState(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 (
+
+
+
+
{zeile.name}
+
{zeile.email}
+
+ {zeile.hatZugang && !zeile.istIchSelbst && (
+
{
+ if (
+ !window.confirm(
+ `${zeile.name} den Zugriff auf ${betreuteName} vollständig entziehen?`,
+ )
+ ) {
+ ereignis.preventDefault()
+ }
+ }}
+ >
+
+
+
+
+ Zugang entziehen
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ setRolle(ereignis.target.value as Rolle)}
+ >
+ {ROLLEN.map((wert) => (
+
+ {ROLLEN_BESCHRIFTUNG[wert]}
+
+ ))}
+
+
+
+ {istMitleser ? (
+
+ Freigegebene Bereiche
+
+ {ALLE_MODULE.map((modul) => {
+ const sensibel = BESONDERS_SENSIBEL.includes(modul)
+ return (
+
+ umschalten(modul)}
+ />
+
+ {MODUL_BESCHRIFTUNG[modul]}
+ {sensibel && (
+
+
+ besonders sensibel
+
+ )}
+
+
+ )
+ })}
+
+
+ {sensibelGewaehlt.length > 0 && (
+
+
+ Das sind besonders schützenswerte Daten
+
+ 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.
+
+
+ )}
+
+
+ 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.
+
+
+ ) : (
+
+ 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.
+
+ )}
+
+ Freigabe speichern
+
+
+ )
+}
diff --git a/src/components/einstellungen/papierkorb-liste.tsx b/src/components/einstellungen/papierkorb-liste.tsx
new file mode 100644
index 0000000..8436e75
--- /dev/null
+++ b/src/components/einstellungen/papierkorb-liste.tsx
@@ -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 (
+
+ {pending ? laeuftText : children}
+
+ )
+}
+
+/**
+ * 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(
+ wiederherstellen,
+ {},
+ )
+ const [loeschstand, loeschen] = useActionState(endgueltigLoeschen, {})
+
+ if (gruppen.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ {gruppen.map((gruppe) => (
+
+
+ {gruppe.bezeichnung}{' '}
+ ({gruppe.eintraege.length})
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/lib/berichte/docx.ts b/src/lib/berichte/docx.ts
index b3d1845..bedc728 100644
--- a/src/lib/berichte/docx.ts
+++ b/src/lib/berichte/docx.ts
@@ -1,213 +1,213 @@
-import 'server-only'
-import {
- AlignmentType,
- BorderStyle,
- Document,
- Footer,
- Header,
- HeadingLevel,
- PageNumber,
- Packer,
- Paragraph,
- TextRun,
- convertMillimetersToTwip,
-} from 'docx'
-import { formatDatum } from '@/lib/utils'
-import { textZuAbschnitten } from '@/lib/berichte/jahresbericht'
-
-/**
- * DOCX-Ausgabe.
- *
- * Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
- * fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
- * Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
- * Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
- * .docx-Datei schon - in Word, LibreOffice oder Google Docs.
- *
- * Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
- * Arbeitsfassung.
- *
- * Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
- * src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
- * funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
- * dieselben, ein Berichtskopf passt ohne Umbau hinein.
- */
-
-export type DocxKopf = {
- titel: string
- betreute: { name: string; geburtsdatum: Date | null }
- behoerde?: string
- aktenzeichen?: string
- zeitraumVon?: Date | null
- zeitraumBis?: Date | null
- stichtag?: Date | null
- rechtsgrundlage?: string
- erstelltAm: Date
- erstelltVon: string
-}
-
-const SCHRIFT = 'Arial'
-
-function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
- return new TextRun({
- text,
- bold: optionen.fett ?? false,
- color: optionen.grau ? '555555' : '000000',
- size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
- font: SCHRIFT,
- })
-}
-
-function kopfzeilen(kopf: DocxKopf): string[] {
- const zeilen: string[] = [
- `Betreute Person: ${kopf.betreute.name}` +
- (kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
- ]
- if (kopf.behoerde) zeilen.push(kopf.behoerde)
- if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
- if (kopf.zeitraumVon || kopf.zeitraumBis) {
- zeilen.push(
- `Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
- )
- }
- if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
- if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
- return zeilen
-}
-
-function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
- return [
- new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
- new Paragraph({
- children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
- spacing: { after: 500 },
- }),
- new Paragraph({
- children: [zeile('')],
- border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
- spacing: { after: 60 },
- }),
- new Paragraph({
- children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
- }),
- ]
-}
-
-/**
- * Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
- * erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
- * Aufzaehlungszeichen - dieselbe Regel wie im PDF.
- */
-export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
- const abschnitte = textZuAbschnitten(text)
-
- const inhalt: Paragraph[] = [
- new Paragraph({
- children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
- spacing: { after: 160 },
- }),
- ...kopfzeilen(kopf).map(
- (text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
- ),
- new Paragraph({
- children: [zeile('')],
- border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
- spacing: { after: 200 },
- }),
- ]
-
- for (const abschnitt of abschnitte) {
- if (abschnitt.titel) {
- inhalt.push(
- new Paragraph({
- children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
- heading: HeadingLevel.HEADING_2,
- spacing: { before: 260, after: 100 },
- }),
- )
- }
- for (const absatz of abschnitt.absaetze) {
- const istAufzaehlung = absatz.startsWith('•')
- inhalt.push(
- new Paragraph({
- children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
- bullet: istAufzaehlung ? { level: 0 } : undefined,
- alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
- spacing: { after: 120, line: 300 },
- }),
- )
- }
- }
-
- inhalt.push(...unterschriftenblock(kopf))
-
- return new Document({
- creator: kopf.erstelltVon,
- title: kopf.titel,
- description: `${kopf.titel} für ${kopf.betreute.name}`,
- sections: [
- {
- properties: {
- page: {
- margin: {
- top: convertMillimetersToTwip(25),
- bottom: convertMillimetersToTwip(20),
- left: convertMillimetersToTwip(25),
- right: convertMillimetersToTwip(20),
- },
- },
- },
- headers: {
- default: new Header({
- children: [
- new Paragraph({
- alignment: AlignmentType.RIGHT,
- children: [
- zeile(
- `${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
- { groesse: 8, grau: true },
- ),
- ],
- }),
- ],
- }),
- },
- footers: {
- default: new Footer({
- children: [
- new Paragraph({
- alignment: AlignmentType.CENTER,
- children: [
- zeile('Seite ', { groesse: 8, grau: true }),
- new TextRun({
- children: [PageNumber.CURRENT],
- size: 16,
- color: '555555',
- font: SCHRIFT,
- }),
- zeile(' von ', { groesse: 8, grau: true }),
- new TextRun({
- children: [PageNumber.TOTAL_PAGES],
- size: 16,
- color: '555555',
- font: SCHRIFT,
- }),
- zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
- groesse: 8,
- grau: true,
- }),
- ],
- }),
- ],
- }),
- },
- children: inhalt,
- },
- ],
- })
-}
-
-/** Fertige .docx-Datei als Buffer. */
-export async function docxErzeugen(kopf: DocxKopf, text: string): Promise {
- return Packer.toBuffer(docxAusEntwurf(kopf, text))
-}
+import 'server-only'
+import {
+ AlignmentType,
+ BorderStyle,
+ Document,
+ Footer,
+ Header,
+ HeadingLevel,
+ PageNumber,
+ Packer,
+ Paragraph,
+ TextRun,
+ convertMillimetersToTwip,
+} from 'docx'
+import { formatDatum } from '@/lib/utils'
+import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
+
+/**
+ * DOCX-Ausgabe.
+ *
+ * Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
+ * fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
+ * Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
+ * Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
+ * .docx-Datei schon - in Word, LibreOffice oder Google Docs.
+ *
+ * Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
+ * Arbeitsfassung.
+ *
+ * Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
+ * src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
+ * funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
+ * dieselben, ein Berichtskopf passt ohne Umbau hinein.
+ */
+
+export type DocxKopf = {
+ titel: string
+ betreute: { name: string; geburtsdatum: Date | null }
+ behoerde?: string
+ aktenzeichen?: string
+ zeitraumVon?: Date | null
+ zeitraumBis?: Date | null
+ stichtag?: Date | null
+ rechtsgrundlage?: string
+ erstelltAm: Date
+ erstelltVon: string
+}
+
+const SCHRIFT = 'Arial'
+
+function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
+ return new TextRun({
+ text,
+ bold: optionen.fett ?? false,
+ color: optionen.grau ? '555555' : '000000',
+ size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
+ font: SCHRIFT,
+ })
+}
+
+function kopfzeilen(kopf: DocxKopf): string[] {
+ const zeilen: string[] = [
+ `Betreute Person: ${kopf.betreute.name}` +
+ (kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
+ ]
+ if (kopf.behoerde) zeilen.push(kopf.behoerde)
+ if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
+ if (kopf.zeitraumVon || kopf.zeitraumBis) {
+ zeilen.push(
+ `Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
+ )
+ }
+ if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
+ if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
+ return zeilen
+}
+
+function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
+ return [
+ new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
+ new Paragraph({
+ children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
+ spacing: { after: 500 },
+ }),
+ new Paragraph({
+ children: [zeile('')],
+ border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
+ spacing: { after: 60 },
+ }),
+ new Paragraph({
+ children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
+ }),
+ ]
+}
+
+/**
+ * Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
+ * erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
+ * Aufzaehlungszeichen - dieselbe Regel wie im PDF.
+ */
+export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
+ const abschnitte = textZuAbschnitten(text)
+
+ const inhalt: Paragraph[] = [
+ new Paragraph({
+ children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
+ spacing: { after: 160 },
+ }),
+ ...kopfzeilen(kopf).map(
+ (text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
+ ),
+ new Paragraph({
+ children: [zeile('')],
+ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
+ spacing: { after: 200 },
+ }),
+ ]
+
+ for (const abschnitt of abschnitte) {
+ if (abschnitt.titel) {
+ inhalt.push(
+ new Paragraph({
+ children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
+ heading: HeadingLevel.HEADING_2,
+ spacing: { before: 260, after: 100 },
+ }),
+ )
+ }
+ for (const absatz of abschnitt.absaetze) {
+ const istAufzaehlung = absatz.startsWith('•')
+ inhalt.push(
+ new Paragraph({
+ children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
+ bullet: istAufzaehlung ? { level: 0 } : undefined,
+ alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
+ spacing: { after: 120, line: 300 },
+ }),
+ )
+ }
+ }
+
+ inhalt.push(...unterschriftenblock(kopf))
+
+ return new Document({
+ creator: kopf.erstelltVon,
+ title: kopf.titel,
+ description: `${kopf.titel} für ${kopf.betreute.name}`,
+ sections: [
+ {
+ properties: {
+ page: {
+ margin: {
+ top: convertMillimetersToTwip(25),
+ bottom: convertMillimetersToTwip(20),
+ left: convertMillimetersToTwip(25),
+ right: convertMillimetersToTwip(20),
+ },
+ },
+ },
+ headers: {
+ default: new Header({
+ children: [
+ new Paragraph({
+ alignment: AlignmentType.RIGHT,
+ children: [
+ zeile(
+ `${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
+ { groesse: 8, grau: true },
+ ),
+ ],
+ }),
+ ],
+ }),
+ },
+ footers: {
+ default: new Footer({
+ children: [
+ new Paragraph({
+ alignment: AlignmentType.CENTER,
+ children: [
+ zeile('Seite ', { groesse: 8, grau: true }),
+ new TextRun({
+ children: [PageNumber.CURRENT],
+ size: 16,
+ color: '555555',
+ font: SCHRIFT,
+ }),
+ zeile(' von ', { groesse: 8, grau: true }),
+ new TextRun({
+ children: [PageNumber.TOTAL_PAGES],
+ size: 16,
+ color: '555555',
+ font: SCHRIFT,
+ }),
+ zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
+ groesse: 8,
+ grau: true,
+ }),
+ ],
+ }),
+ ],
+ }),
+ },
+ children: inhalt,
+ },
+ ],
+ })
+}
+
+/** Fertige .docx-Datei als Buffer. */
+export async function docxErzeugen(kopf: DocxKopf, text: string): Promise {
+ return Packer.toBuffer(docxAusEntwurf(kopf, text))
+}
diff --git a/src/lib/berichte/jahresbericht.ausgabe.ts b/src/lib/berichte/jahresbericht.ausgabe.ts
new file mode 100644
index 0000000..c5cf9ab
--- /dev/null
+++ b/src/lib/berichte/jahresbericht.ausgabe.ts
@@ -0,0 +1,134 @@
+import 'server-only'
+import { createElement } from 'react'
+import type { Berichtskopf } from '@/lib/pdf/rahmen'
+import { pdfErzeugen } from '@/lib/pdf/erzeugen'
+import { db, nichtGeloescht } from '@/lib/db'
+import { oeffnen } from '@/lib/crypto/feld'
+import {
+ jahresberichtEntwurfText,
+ type JahresberichtQuellen,
+} from '@/lib/berichte/jahresbericht.entwurf'
+import { JahresberichtDokument } from '@/lib/berichte/jahresbericht.inhalt'
+
+/**
+ * Jahresbericht - Datenbeschaffung und PDF-Ausgabe.
+ *
+ * Die Textbausteine stehen in jahresbericht.entwurf.ts und kommen ohne
+ * Datenbank aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins
+ * PDF geht, ist IMMER der gespeicherte, vom Betreuer ueberarbeitete
+ * Entwurfstext - nie ein frisch berechneter. Sonst stuende im abgegebenen
+ * Bericht etwas anderes als das, was auf dem Bildschirm zur Freigabe stand.
+ */
+
+/**
+ * Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
+ * Zugriffsberechtigung bereits geprueft (erfordereZugang); die Abfragen
+ * sind trotzdem durchgehend auf die betreuteId eingegrenzt.
+ */
+export async function jahresberichtQuellenLaden(
+ betreuteId: string,
+ zeitraumVon: Date,
+ zeitraumBis: Date,
+): Promise {
+ const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
+ db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
+ db.kontaktprotokoll.findMany({
+ where: { betreuteId, datum: { gte: zeitraumVon, lte: zeitraumBis }, ...nichtGeloescht },
+ orderBy: { datum: 'asc' },
+ }),
+ db.gesundheitsereignis.findMany({
+ where: { betreuteId, datum: { gte: zeitraumVon, lte: zeitraumBis }, ...nichtGeloescht },
+ orderBy: { datum: 'asc' },
+ }),
+ db.vorgang.findMany({
+ where: {
+ betreuteId,
+ ...nichtGeloescht,
+ // Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
+ // Bericht des Vorjahres - hier waere es nur Ballast.
+ OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
+ },
+ orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
+ }),
+ ])
+
+ const betreute = oeffnen('Betreute', roheBetreute)
+
+ return {
+ betreute: {
+ anrede: betreute.anrede,
+ vorname: betreute.vorname,
+ nachname: betreute.nachname,
+ geburtsdatum: betreute.geburtsdatum,
+ heimName: betreute.heimName,
+ heimStrasse: betreute.heimStrasse,
+ heimPlz: betreute.heimPlz,
+ heimOrt: betreute.heimOrt,
+ heimZimmer: betreute.heimZimmer,
+ heimEinzugAm: betreute.heimEinzugAm,
+ fruehereStrasse: betreute.fruehereStrasse,
+ frueherePlz: betreute.frueherePlz,
+ fruehererOrt: betreute.fruehererOrt,
+ pflegegrad: betreute.pflegegrad,
+ pflegegradSeit: betreute.pflegegradSeit,
+ schwerbehinderungGdB: betreute.schwerbehinderungGdB,
+ schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
+ diagnosen: betreute.diagnosen,
+ ermittelterWille: betreute.ermittelterWille,
+ patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
+ vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
+ aufgabenkreise: betreute.aufgabenkreise,
+ aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
+ betreuungsgericht: betreute.betreuungsgericht,
+ aktenzeichenGericht: betreute.aktenzeichenGericht,
+ bestellungAm: betreute.bestellungAm,
+ bestellungBis: betreute.bestellungBis,
+ },
+ zeitraumVon,
+ zeitraumBis,
+ kontakte: kontakte.map((k) => {
+ const offen = oeffnen('Kontaktprotokoll', k)
+ return {
+ datum: k.datum,
+ art: k.art,
+ mitBetreuter: k.mitBetreuter,
+ dauerMinuten: k.dauerMinuten,
+ gespraechspartner: k.gespraechspartner,
+ wuensche: offen.wuensche,
+ }
+ }),
+ gesundheit: gesundheit.map((e) => ({
+ datum: e.datum,
+ bisDatum: e.bisDatum,
+ art: e.art,
+ titel: e.titel,
+ einrichtung: e.einrichtung,
+ abwesenheitstage: e.abwesenheitstage,
+ })),
+ vorgaenge: vorgaenge.map((v) => ({
+ titel: v.titel,
+ kategorie: v.kategorie,
+ status: v.status,
+ naechsterSchritt: v.naechsterSchritt,
+ gegenstelle: v.gegenstelle,
+ genehmigungspflichtig: v.genehmigungspflichtig,
+ erledigtAm: v.erledigtAm,
+ })),
+ }
+}
+
+/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu aufbauen". */
+export async function jahresberichtEntwurfAusDatenbank(
+ betreuteId: string,
+ zeitraumVon: Date,
+ zeitraumBis: Date,
+): Promise {
+ return jahresberichtEntwurfText(
+ await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
+ )
+}
+
+/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
+export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise {
+ return pdfErzeugen(createElement(JahresberichtDokument, { kopf, text }))
+}
diff --git a/src/lib/berichte/jahresbericht.entwurf.ts b/src/lib/berichte/jahresbericht.entwurf.ts
new file mode 100644
index 0000000..99117b7
--- /dev/null
+++ b/src/lib/berichte/jahresbericht.entwurf.ts
@@ -0,0 +1,694 @@
+import type {
+ Aufgabenkreis,
+ GesundheitsArt,
+ KontaktArt,
+ VorgangKategorie,
+ VorgangStatus,
+} from '@prisma/client'
+import { formatDatum } from '@/lib/utils'
+
+/**
+ * Jahresbericht nach § 1863 BGB - Textentwurf.
+ *
+ * Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
+ * zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
+ * und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
+ * daraus wird, entscheidet der Betreuer: Der Text ist im Browser
+ * bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
+ *
+ * Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
+ * dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
+ * Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
+ * wurden, tauchen gar nicht auf.
+ *
+ * Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
+ * Daten, damit die Textbausteine testbar bleiben.
+ */
+
+// -----------------------------------------------------------------------------
+// Eingangsdaten
+// -----------------------------------------------------------------------------
+
+export type JahresberichtBetreute = {
+ anrede: string | null
+ vorname: string
+ nachname: string
+ geburtsdatum: Date | null
+ heimName: string | null
+ heimStrasse: string | null
+ heimPlz: string | null
+ heimOrt: string | null
+ heimZimmer: string | null
+ heimEinzugAm: Date | null
+ fruehereStrasse: string | null
+ frueherePlz: string | null
+ fruehererOrt: string | null
+ pflegegrad: number | null
+ pflegegradSeit: Date | null
+ schwerbehinderungGdB: number | null
+ schwerbehinderungMerkzeichen: string | null
+ /** Klartext aus diagnosenVerschluesselt. */
+ diagnosen: string | null
+ /** Klartext aus ermittelterWilleVerschluesselt. */
+ ermittelterWille: string | null
+ patientenverfuegungVorhanden: boolean
+ vorsorgevollmachtVorhanden: boolean
+ aufgabenkreise: Aufgabenkreis[]
+ aufgabenkreiseFreitext: string | null
+ betreuungsgericht: string | null
+ aktenzeichenGericht: string | null
+ bestellungAm: Date | null
+ bestellungBis: Date | null
+}
+
+export type JahresberichtKontakt = {
+ datum: Date
+ art: KontaktArt
+ mitBetreuter: boolean
+ dauerMinuten: number | null
+ gespraechspartner: string | null
+ /** Klartext aus wuenscheVerschluesselt. */
+ wuensche: string | null
+}
+
+export type JahresberichtGesundheit = {
+ datum: Date
+ bisDatum: Date | null
+ art: GesundheitsArt
+ titel: string
+ einrichtung: string | null
+ abwesenheitstage: number | null
+}
+
+export type JahresberichtVorgang = {
+ titel: string
+ kategorie: VorgangKategorie
+ status: VorgangStatus
+ naechsterSchritt: string | null
+ gegenstelle: string | null
+ genehmigungspflichtig: boolean
+ erledigtAm: Date | null
+}
+
+export type JahresberichtQuellen = {
+ betreute: JahresberichtBetreute
+ zeitraumVon: Date
+ zeitraumBis: Date
+ kontakte: JahresberichtKontakt[]
+ gesundheit: JahresberichtGesundheit[]
+ vorgaenge: JahresberichtVorgang[]
+}
+
+export type Berichtsabschnitt = {
+ nummer: number
+ titel: string
+ rechtsgrundlage: string
+ absaetze: string[]
+}
+
+// -----------------------------------------------------------------------------
+// Beschriftungen
+// -----------------------------------------------------------------------------
+
+/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
+export const ERGAENZEN = '[bitte ergänzen]'
+
+export const ENTWURFSHINWEIS =
+ 'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
+ 'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
+ 'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
+ 'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
+
+export const KONTAKTART_EINZAHL: Record = {
+ BESUCH: 'Besuch',
+ TELEFONAT: 'Telefonat',
+ EMAIL: 'E-Mail',
+ BRIEF: 'Brief',
+ VIDEOCALL: 'Videotelefonat',
+ FAX: 'Fax',
+ VORSPRACHE: 'persönliche Vorsprache',
+}
+
+export const KONTAKTART_MEHRZAHL: Record = {
+ BESUCH: 'Besuche',
+ TELEFONAT: 'Telefonate',
+ EMAIL: 'E-Mails',
+ BRIEF: 'Briefe',
+ VIDEOCALL: 'Videotelefonate',
+ FAX: 'Faxe',
+ VORSPRACHE: 'persönliche Vorsprachen',
+}
+
+export const GESUNDHEITSART_MEHRZAHL: Record = {
+ ARZTBESUCH: 'Arztbesuche',
+ KRANKENHAUS: 'Krankenhausaufenthalte',
+ EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
+ STURZ: 'Stürze',
+ MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
+ SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
+ INHALATION: 'Inhalationsbehandlungen',
+ HILFSMITTEL: 'Hilfsmittelversorgungen',
+ IMPFUNG: 'Impfungen',
+ REHA: 'Rehabilitationsmaßnahmen',
+ PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
+ HEILMITTEL: 'Heilmittelbehandlungen',
+ SONSTIGES: 'sonstige gesundheitliche Ereignisse',
+}
+
+export const KATEGORIE_BESCHRIFTUNG: Record = {
+ GERICHT: 'Betreuungsgericht',
+ SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
+ IMMOBILIE: 'Immobilie und Wohnraum',
+ VERSICHERUNG: 'Versicherungen',
+ HEIM_PFLEGE: 'Heim und Pflege',
+ VERTRAEGE: 'Verträge',
+ STEUERN: 'Steuern',
+ SONSTIGES: 'Sonstiges',
+}
+
+export const AUFGABENKREIS_BESCHRIFTUNG: Record = {
+ VERMOEGENSSORGE: 'Vermögenssorge',
+ GESUNDHEITSSORGE: 'Gesundheitssorge',
+ AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
+ WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
+ BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
+ POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
+ RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
+ ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
+ 'Entscheidung über freiheitsentziehende Maßnahmen',
+ SONSTIGES: 'weitere Aufgaben',
+}
+
+export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
+ { nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
+ {
+ nummer: 2,
+ titel: 'Persönlicher Kontakt zur betreuten Person',
+ rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
+ },
+ {
+ nummer: 3,
+ titel: 'Wünsche der betreuten Person und ihre Umsetzung',
+ rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
+ },
+ {
+ nummer: 4,
+ titel: 'Erledigte und laufende Angelegenheiten',
+ rechtsgrundlage: '§ 1863 Abs. 3 BGB',
+ },
+ {
+ nummer: 5,
+ titel: 'Einschätzung zur Fortführung der Betreuung',
+ rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
+ },
+]
+
+// -----------------------------------------------------------------------------
+// Sprachliche Helfer
+// -----------------------------------------------------------------------------
+
+/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
+export function aufzaehlung(teile: readonly string[]): string {
+ const sauber = teile.filter((t) => t.trim() !== '')
+ if (sauber.length === 0) return ''
+ if (sauber.length === 1) return sauber[0]!
+ return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
+}
+
+/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
+export function malAngabe(anzahl: number): string {
+ const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
+ return woerter[anzahl] ?? `${anzahl}-mal`
+}
+
+/** 95 Minuten -> "1 Stunde und 35 Minuten". */
+export function dauerAusgeschrieben(minuten: number): string {
+ const stunden = Math.floor(minuten / 60)
+ const rest = minuten % 60
+ const teile: string[] = []
+ if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
+ if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
+ return aufzaehlung(teile)
+}
+
+/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
+export function vollerName(betreute: Pick): string {
+ return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
+}
+
+/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
+export function kurzName(betreute: Pick): string {
+ return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
+}
+
+/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
+function pronomen(betreute: Pick): { nominativ: string; possessiv: string } {
+ const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
+ return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
+}
+
+function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
+ const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
+ return zeile.trim() === '' ? null : zeile
+}
+
+function tageZwischen(von: Date, bis: Date): number {
+ return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
+}
+
+function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
+ return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
+}
+
+function haeufigkeit(werte: T[]): Map {
+ const karte = new Map()
+ for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
+ return karte
+}
+
+// -----------------------------------------------------------------------------
+// Die fuenf Abschnitte
+// -----------------------------------------------------------------------------
+
+function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
+ const { betreute, zeitraumVon, zeitraumBis } = quellen
+ const name = kurzName(betreute)
+ const pron = pronomen(betreute)
+ const absaetze: string[] = []
+
+ // --- Aufenthalt ---
+ const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
+ if (betreute.heimName) {
+ const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
+ const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
+ const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
+ absaetze.push(
+ `${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
+ `in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
+ `im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
+ )
+ } else {
+ absaetze.push(
+ `${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
+ `liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
+ `Berichtszeitraum gelebt hat.`,
+ )
+ }
+
+ const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
+ if (frueher) {
+ absaetze.push(
+ `Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
+ `Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
+ )
+ }
+
+ // --- Pflege und Behinderung ---
+ const pflegesaetze: string[] = []
+ if (betreute.pflegegrad !== null) {
+ const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
+ pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
+ } else {
+ pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
+ }
+ if (betreute.schwerbehinderungGdB !== null) {
+ const merk = betreute.schwerbehinderungMerkzeichen
+ ? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
+ : ''
+ pflegesaetze.push(
+ `Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
+ )
+ }
+ absaetze.push(pflegesaetze.join(' '))
+
+ // --- Gesundheitszustand im Berichtszeitraum ---
+ const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
+ const gesundheitssaetze: string[] = []
+
+ if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
+ gesundheitssaetze.push(
+ `Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
+ )
+ }
+
+ if (ereignisse.length === 0) {
+ gesundheitssaetze.push(
+ `Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
+ `keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
+ )
+ } else {
+ const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
+ const aufzaehlungTeile = [...nachArt.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
+ gesundheitssaetze.push(
+ `Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
+ `${aufzaehlung(aufzaehlungTeile)}.`,
+ )
+
+ const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
+ if (klinik.length > 0) {
+ const beschreibungen = klinik.map((e) => {
+ const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
+ const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
+ return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
+ })
+ gesundheitssaetze.push(
+ `Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
+ )
+ }
+
+ const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
+ if (abwesend > 0) {
+ gesundheitssaetze.push(
+ `Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
+ )
+ }
+ }
+ absaetze.push(gesundheitssaetze.join(' '))
+
+ // --- Vorsorge ---
+ const vorsorge: string[] = []
+ vorsorge.push(
+ betreute.patientenverfuegungVorhanden
+ ? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
+ : 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
+ )
+ vorsorge.push(
+ betreute.vorsorgevollmachtVorhanden
+ ? 'Eine Vorsorgevollmacht ist vorhanden.'
+ : 'Eine Vorsorgevollmacht ist nicht vorhanden.',
+ )
+ absaetze.push(vorsorge.join(' '))
+
+ return absaetze
+}
+
+function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
+ const { betreute, zeitraumVon, zeitraumBis } = quellen
+ const name = kurzName(betreute)
+ const absaetze: string[] = []
+
+ const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
+ const mitIhr = imFenster
+ .filter((k) => k.mitBetreuter)
+ .sort((a, b) => a.datum.getTime() - b.datum.getTime())
+ const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
+
+ absaetze.push(
+ `Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
+ `${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
+ )
+
+ if (mitIhr.length === 0) {
+ absaetze.push(
+ `Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
+ `nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
+ `persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
+ `nach — ${ERGAENZEN}.`,
+ )
+ } else {
+ const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
+ const teile = [...nachArt.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([art, anzahl]) =>
+ anzahl === 1
+ ? `ein ${KONTAKTART_EINZAHL[art]}`
+ : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
+ )
+
+ absaetze.push(
+ `Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
+ `${aufzaehlung(teile)}.`,
+ )
+
+ const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
+ const luecken = mitIhr
+ .slice(1)
+ .map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
+ const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
+ const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
+ if (groessteLuecke > 0) {
+ rhythmus.push(
+ `Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
+ )
+ }
+ rhythmus.push(
+ `Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
+ `der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
+ )
+ absaetze.push(rhythmus.join(' '))
+
+ const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
+ if (minuten > 0) {
+ absaetze.push(
+ `Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
+ `wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
+ `das Befinden und die persönliche Lage von ${name}.`,
+ )
+ }
+ }
+
+ if (mitDritten.length > 0) {
+ const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
+ const teile = [...nachArt.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([art, anzahl]) =>
+ anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
+ )
+ const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
+ const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
+ absaetze.push(
+ `Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
+ `${aufzaehlung(teile)}.${wer}`,
+ )
+ }
+
+ return absaetze
+}
+
+function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
+ const { betreute, zeitraumVon, zeitraumBis } = quellen
+ const name = kurzName(betreute)
+ const absaetze: string[] = []
+
+ absaetze.push(
+ `Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
+ `${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
+ `festgehalten.`,
+ )
+
+ const mitWunsch = quellen.kontakte
+ .filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
+ .filter((k) => (k.wuensche ?? '').trim() !== '')
+ .sort((a, b) => a.datum.getTime() - b.datum.getTime())
+
+ if (mitWunsch.length === 0) {
+ absaetze.push(
+ `Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
+ `${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
+ `Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
+ )
+ } else {
+ absaetze.push(
+ `Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
+ )
+ for (const kontakt of mitWunsch) {
+ absaetze.push(
+ `• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
+ `${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
+ )
+ }
+ absaetze.push(
+ `Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
+ `entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
+ )
+ }
+
+ if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
+ absaetze.push(
+ `Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
+ `${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
+ )
+ }
+
+ return absaetze
+}
+
+function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
+ const { zeitraumVon, zeitraumBis } = quellen
+ const absaetze: string[] = []
+
+ if (quellen.vorgaenge.length === 0) {
+ absaetze.push(
+ `Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
+ `hast du bearbeitet?`,
+ )
+ return absaetze
+ }
+
+ const erledigt = quellen.vorgaenge.filter(
+ (v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
+ )
+ const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
+
+ absaetze.push(
+ `Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
+ `${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
+ )
+
+ const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
+ KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
+ )
+
+ for (const kategorie of kategorien) {
+ const fertig = erledigt.filter((v) => v.kategorie === kategorie)
+ const offen = laufend.filter((v) => v.kategorie === kategorie)
+ const saetze: string[] = []
+
+ if (fertig.length > 0) {
+ const titel = fertig.map((v) =>
+ v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
+ )
+ saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
+ }
+ if (offen.length > 0) {
+ const titel = offen.map((v) => {
+ const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
+ const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
+ return `${v.titel}${wer}${schritt}`
+ })
+ saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
+ }
+
+ absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
+ }
+
+ const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
+ if (genehmigungspflichtig.length > 0) {
+ absaetze.push(
+ `Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
+ `Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
+ )
+ }
+
+ return absaetze
+}
+
+function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
+ const { betreute } = quellen
+ const name = kurzName(betreute)
+ const absaetze: string[] = []
+
+ const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
+ if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
+ kreise.push(betreute.aufgabenkreiseFreitext.trim())
+ }
+
+ absaetze.push(
+ kreise.length > 0
+ ? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
+ : `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
+ )
+
+ const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
+ const begruendung =
+ laufend.length > 0
+ ? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
+ `${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
+ `ist daher nicht erkennbar.`
+ : `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
+
+ absaetze.push(
+ `Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
+ `fortzuführen. ${begruendung}`,
+ )
+
+ absaetze.push(
+ `Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
+ `einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
+ `${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
+ `einzuschränken oder aufzuheben.`,
+ )
+
+ absaetze.push(
+ `${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
+ `falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
+ )
+
+ return absaetze
+}
+
+// -----------------------------------------------------------------------------
+// Zusammenbau
+// -----------------------------------------------------------------------------
+
+/**
+ * Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
+ * gleicher Text - damit sich der Entwurf testen laesst.
+ */
+export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
+ const bauer = [
+ abschnittPersoenlicheVerhaeltnisse,
+ abschnittKontakt,
+ abschnittWuensche,
+ abschnittAngelegenheiten,
+ abschnittEinschaetzung,
+ ]
+
+ return GLIEDERUNG.map((eintrag, i) => ({
+ nummer: eintrag.nummer,
+ titel: eintrag.titel,
+ rechtsgrundlage: eintrag.rechtsgrundlage,
+ absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
+ }))
+}
+
+/**
+ * Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
+ * abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
+ * damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
+ */
+export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
+ return abschnitte
+ .map((abschnitt) =>
+ [`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
+ )
+ .join('\n\n')
+ .trim()
+}
+
+/** Direktweg von den Quellen zum Text. */
+export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
+ return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
+}
+
+/**
+ * Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
+ * ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
+ * nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
+ * auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
+ */
+export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
+ const zeilen = text.replace(/\r\n/g, '\n').split('\n')
+ const abschnitte: { titel: string | null; absaetze: string[] }[] = []
+ let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
+
+ for (const zeile of zeilen) {
+ if (/^\s*\d+\.\s+\S/.test(zeile)) {
+ if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
+ aktuell = { titel: zeile.trim(), absaetze: [] }
+ continue
+ }
+ if (zeile.trim() === '') continue
+ aktuell.absaetze.push(zeile.trim())
+ }
+ if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
+
+ return abschnitte
+}
+
+/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
+export function offeneErgaenzungen(text: string): number {
+ return text.split(ERGAENZEN).length - 1
+}
diff --git a/src/lib/berichte/jahresbericht.inhalt.tsx b/src/lib/berichte/jahresbericht.inhalt.tsx
new file mode 100644
index 0000000..03da016
--- /dev/null
+++ b/src/lib/berichte/jahresbericht.inhalt.tsx
@@ -0,0 +1,89 @@
+import { StyleSheet, Text, View } from '@react-pdf/renderer'
+import { Berichtsdokument, type Berichtskopf } from '@/lib/pdf/rahmen'
+import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
+
+/**
+ * Der Satz des Jahresberichts fuer das PDF.
+ *
+ * Warum liegt das nicht in jahresbericht.tsx? Weil neben dieser Datei eine
+ * jahresbericht.ts steht und beide Dateinamen sich nur in der Endung
+ * unterscheiden. Webpack probiert ".tsx" vor ".ts", TypeScript und Vitest
+ * umgekehrt - ein Import ohne Endung waere je nach Werkzeug eine andere
+ * Datei. Deshalb tragen die Bausteine eindeutige Namen
+ * (jahresbericht.entwurf, jahresbericht.inhalt, jahresbericht.ausgabe),
+ * und jahresbericht.ts wie jahresbericht.tsx reichen sie unveraendert
+ * weiter. So bekommt jeder Aufrufer dieselben Namen, egal welches Werkzeug
+ * aufloest.
+ */
+
+const stile = StyleSheet.create({
+ ueberschrift: {
+ fontSize: 12,
+ fontFamily: 'Helvetica-Bold',
+ marginTop: 14,
+ marginBottom: 4,
+ },
+ absatz: {
+ fontSize: 10,
+ lineHeight: 1.5,
+ marginBottom: 6,
+ textAlign: 'justify',
+ },
+ aufzaehlung: {
+ fontSize: 10,
+ lineHeight: 1.5,
+ marginBottom: 3,
+ marginLeft: 12,
+ },
+ vorspann: {
+ fontSize: 9,
+ lineHeight: 1.4,
+ marginBottom: 10,
+ color: '#4b5563',
+ },
+})
+
+/**
+ * Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
+ * fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
+ * Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
+ * das sich im Textfeld auch von Hand fortschreiben laesst.
+ */
+export function JahresberichtInhalt({ text }: { text: string }): React.JSX.Element {
+ const abschnitte = textZuAbschnitten(text)
+
+ return (
+
+ {abschnitte.map((abschnitt, i) => (
+
+ {abschnitt.titel && {abschnitt.titel} }
+ {abschnitt.absaetze.map((absatz, j) => (
+
+ {absatz}
+
+ ))}
+
+ ))}
+
+ )
+}
+
+export function JahresberichtDokument({
+ kopf,
+ text,
+ vorspann,
+}: {
+ kopf: Berichtskopf
+ text: string
+ vorspann?: string
+}): React.JSX.Element {
+ return (
+
+ {vorspann && {vorspann} }
+
+
+ )
+}
diff --git a/src/lib/berichte/jahresbericht.ts b/src/lib/berichte/jahresbericht.ts
index 99117b7..470fd33 100644
--- a/src/lib/berichte/jahresbericht.ts
+++ b/src/lib/berichte/jahresbericht.ts
@@ -1,694 +1,25 @@
-import type {
- Aufgabenkreis,
- GesundheitsArt,
- KontaktArt,
- VorgangKategorie,
- VorgangStatus,
-} from '@prisma/client'
-import { formatDatum } from '@/lib/utils'
-
/**
- * Jahresbericht nach § 1863 BGB - Textentwurf.
+ * Jahresbericht nach § 1863 BGB - gemeinsame Anlaufstelle.
*
- * Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
- * zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
- * und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
- * daraus wird, entscheidet der Betreuer: Der Text ist im Browser
- * bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
+ * Der Bericht besteht aus drei Bausteinen:
*
- * Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
- * dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
- * Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
- * wurden, tauchen gar nicht auf.
+ * jahresbericht.entwurf.ts Textbausteine, rein, ohne Datenbank
+ * jahresbericht.inhalt.tsx Satz fuer das PDF (React)
+ * jahresbericht.ausgabe.ts Daten laden und PDF erzeugen
*
- * Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
- * Daten, damit die Textbausteine testbar bleiben.
+ * Warum diese Aufteilung, und warum diese Datei? Neben ihr liegt eine
+ * gleichnamige .tsx. Webpack probiert beim Import ohne Endung ".tsx" vor
+ * ".ts", TypeScript und Vitest nehmen die ".ts" - derselbe Importpfad
+ * waere je nach Werkzeug eine andere Datei. Deshalb tragen die Bausteine
+ * eindeutige Namen, und die beiden gleichnamigen Dateien reichen exakt
+ * dieselben Namen weiter. Wer "@/lib/berichte/jahresbericht" importiert,
+ * bekommt so immer dieselbe Schnittstelle.
+ *
+ * Wer nur die Textbausteine braucht - Tests, die DOCX-Ausgabe -, importiert
+ * besser direkt "@/lib/berichte/jahresbericht.entwurf": das spart Datenbank
+ * und PDF-Bibliothek.
*/
-// -----------------------------------------------------------------------------
-// Eingangsdaten
-// -----------------------------------------------------------------------------
-
-export type JahresberichtBetreute = {
- anrede: string | null
- vorname: string
- nachname: string
- geburtsdatum: Date | null
- heimName: string | null
- heimStrasse: string | null
- heimPlz: string | null
- heimOrt: string | null
- heimZimmer: string | null
- heimEinzugAm: Date | null
- fruehereStrasse: string | null
- frueherePlz: string | null
- fruehererOrt: string | null
- pflegegrad: number | null
- pflegegradSeit: Date | null
- schwerbehinderungGdB: number | null
- schwerbehinderungMerkzeichen: string | null
- /** Klartext aus diagnosenVerschluesselt. */
- diagnosen: string | null
- /** Klartext aus ermittelterWilleVerschluesselt. */
- ermittelterWille: string | null
- patientenverfuegungVorhanden: boolean
- vorsorgevollmachtVorhanden: boolean
- aufgabenkreise: Aufgabenkreis[]
- aufgabenkreiseFreitext: string | null
- betreuungsgericht: string | null
- aktenzeichenGericht: string | null
- bestellungAm: Date | null
- bestellungBis: Date | null
-}
-
-export type JahresberichtKontakt = {
- datum: Date
- art: KontaktArt
- mitBetreuter: boolean
- dauerMinuten: number | null
- gespraechspartner: string | null
- /** Klartext aus wuenscheVerschluesselt. */
- wuensche: string | null
-}
-
-export type JahresberichtGesundheit = {
- datum: Date
- bisDatum: Date | null
- art: GesundheitsArt
- titel: string
- einrichtung: string | null
- abwesenheitstage: number | null
-}
-
-export type JahresberichtVorgang = {
- titel: string
- kategorie: VorgangKategorie
- status: VorgangStatus
- naechsterSchritt: string | null
- gegenstelle: string | null
- genehmigungspflichtig: boolean
- erledigtAm: Date | null
-}
-
-export type JahresberichtQuellen = {
- betreute: JahresberichtBetreute
- zeitraumVon: Date
- zeitraumBis: Date
- kontakte: JahresberichtKontakt[]
- gesundheit: JahresberichtGesundheit[]
- vorgaenge: JahresberichtVorgang[]
-}
-
-export type Berichtsabschnitt = {
- nummer: number
- titel: string
- rechtsgrundlage: string
- absaetze: string[]
-}
-
-// -----------------------------------------------------------------------------
-// Beschriftungen
-// -----------------------------------------------------------------------------
-
-/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
-export const ERGAENZEN = '[bitte ergänzen]'
-
-export const ENTWURFSHINWEIS =
- 'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
- 'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
- 'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
- 'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
-
-export const KONTAKTART_EINZAHL: Record = {
- BESUCH: 'Besuch',
- TELEFONAT: 'Telefonat',
- EMAIL: 'E-Mail',
- BRIEF: 'Brief',
- VIDEOCALL: 'Videotelefonat',
- FAX: 'Fax',
- VORSPRACHE: 'persönliche Vorsprache',
-}
-
-export const KONTAKTART_MEHRZAHL: Record = {
- BESUCH: 'Besuche',
- TELEFONAT: 'Telefonate',
- EMAIL: 'E-Mails',
- BRIEF: 'Briefe',
- VIDEOCALL: 'Videotelefonate',
- FAX: 'Faxe',
- VORSPRACHE: 'persönliche Vorsprachen',
-}
-
-export const GESUNDHEITSART_MEHRZAHL: Record = {
- ARZTBESUCH: 'Arztbesuche',
- KRANKENHAUS: 'Krankenhausaufenthalte',
- EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
- STURZ: 'Stürze',
- MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
- SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
- INHALATION: 'Inhalationsbehandlungen',
- HILFSMITTEL: 'Hilfsmittelversorgungen',
- IMPFUNG: 'Impfungen',
- REHA: 'Rehabilitationsmaßnahmen',
- PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
- HEILMITTEL: 'Heilmittelbehandlungen',
- SONSTIGES: 'sonstige gesundheitliche Ereignisse',
-}
-
-export const KATEGORIE_BESCHRIFTUNG: Record = {
- GERICHT: 'Betreuungsgericht',
- SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
- IMMOBILIE: 'Immobilie und Wohnraum',
- VERSICHERUNG: 'Versicherungen',
- HEIM_PFLEGE: 'Heim und Pflege',
- VERTRAEGE: 'Verträge',
- STEUERN: 'Steuern',
- SONSTIGES: 'Sonstiges',
-}
-
-export const AUFGABENKREIS_BESCHRIFTUNG: Record = {
- VERMOEGENSSORGE: 'Vermögenssorge',
- GESUNDHEITSSORGE: 'Gesundheitssorge',
- AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
- WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
- BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
- POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
- RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
- ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
- 'Entscheidung über freiheitsentziehende Maßnahmen',
- SONSTIGES: 'weitere Aufgaben',
-}
-
-export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
- { nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
- {
- nummer: 2,
- titel: 'Persönlicher Kontakt zur betreuten Person',
- rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
- },
- {
- nummer: 3,
- titel: 'Wünsche der betreuten Person und ihre Umsetzung',
- rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
- },
- {
- nummer: 4,
- titel: 'Erledigte und laufende Angelegenheiten',
- rechtsgrundlage: '§ 1863 Abs. 3 BGB',
- },
- {
- nummer: 5,
- titel: 'Einschätzung zur Fortführung der Betreuung',
- rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
- },
-]
-
-// -----------------------------------------------------------------------------
-// Sprachliche Helfer
-// -----------------------------------------------------------------------------
-
-/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
-export function aufzaehlung(teile: readonly string[]): string {
- const sauber = teile.filter((t) => t.trim() !== '')
- if (sauber.length === 0) return ''
- if (sauber.length === 1) return sauber[0]!
- return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
-}
-
-/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
-export function malAngabe(anzahl: number): string {
- const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
- return woerter[anzahl] ?? `${anzahl}-mal`
-}
-
-/** 95 Minuten -> "1 Stunde und 35 Minuten". */
-export function dauerAusgeschrieben(minuten: number): string {
- const stunden = Math.floor(minuten / 60)
- const rest = minuten % 60
- const teile: string[] = []
- if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
- if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
- return aufzaehlung(teile)
-}
-
-/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
-export function vollerName(betreute: Pick): string {
- return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
-}
-
-/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
-export function kurzName(betreute: Pick): string {
- return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
-}
-
-/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
-function pronomen(betreute: Pick): { nominativ: string; possessiv: string } {
- const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
- return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
-}
-
-function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
- const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
- return zeile.trim() === '' ? null : zeile
-}
-
-function tageZwischen(von: Date, bis: Date): number {
- return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
-}
-
-function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
- return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
-}
-
-function haeufigkeit(werte: T[]): Map {
- const karte = new Map()
- for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
- return karte
-}
-
-// -----------------------------------------------------------------------------
-// Die fuenf Abschnitte
-// -----------------------------------------------------------------------------
-
-function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
- const { betreute, zeitraumVon, zeitraumBis } = quellen
- const name = kurzName(betreute)
- const pron = pronomen(betreute)
- const absaetze: string[] = []
-
- // --- Aufenthalt ---
- const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
- if (betreute.heimName) {
- const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
- const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
- const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
- absaetze.push(
- `${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
- `in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
- `im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
- )
- } else {
- absaetze.push(
- `${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
- `liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
- `Berichtszeitraum gelebt hat.`,
- )
- }
-
- const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
- if (frueher) {
- absaetze.push(
- `Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
- `Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
- )
- }
-
- // --- Pflege und Behinderung ---
- const pflegesaetze: string[] = []
- if (betreute.pflegegrad !== null) {
- const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
- pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
- } else {
- pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
- }
- if (betreute.schwerbehinderungGdB !== null) {
- const merk = betreute.schwerbehinderungMerkzeichen
- ? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
- : ''
- pflegesaetze.push(
- `Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
- )
- }
- absaetze.push(pflegesaetze.join(' '))
-
- // --- Gesundheitszustand im Berichtszeitraum ---
- const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
- const gesundheitssaetze: string[] = []
-
- if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
- gesundheitssaetze.push(
- `Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
- )
- }
-
- if (ereignisse.length === 0) {
- gesundheitssaetze.push(
- `Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
- `keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
- )
- } else {
- const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
- const aufzaehlungTeile = [...nachArt.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
- gesundheitssaetze.push(
- `Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
- `${aufzaehlung(aufzaehlungTeile)}.`,
- )
-
- const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
- if (klinik.length > 0) {
- const beschreibungen = klinik.map((e) => {
- const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
- const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
- return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
- })
- gesundheitssaetze.push(
- `Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
- )
- }
-
- const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
- if (abwesend > 0) {
- gesundheitssaetze.push(
- `Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
- )
- }
- }
- absaetze.push(gesundheitssaetze.join(' '))
-
- // --- Vorsorge ---
- const vorsorge: string[] = []
- vorsorge.push(
- betreute.patientenverfuegungVorhanden
- ? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
- : 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
- )
- vorsorge.push(
- betreute.vorsorgevollmachtVorhanden
- ? 'Eine Vorsorgevollmacht ist vorhanden.'
- : 'Eine Vorsorgevollmacht ist nicht vorhanden.',
- )
- absaetze.push(vorsorge.join(' '))
-
- return absaetze
-}
-
-function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
- const { betreute, zeitraumVon, zeitraumBis } = quellen
- const name = kurzName(betreute)
- const absaetze: string[] = []
-
- const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
- const mitIhr = imFenster
- .filter((k) => k.mitBetreuter)
- .sort((a, b) => a.datum.getTime() - b.datum.getTime())
- const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
-
- absaetze.push(
- `Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
- `${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
- )
-
- if (mitIhr.length === 0) {
- absaetze.push(
- `Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
- `nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
- `persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
- `nach — ${ERGAENZEN}.`,
- )
- } else {
- const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
- const teile = [...nachArt.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([art, anzahl]) =>
- anzahl === 1
- ? `ein ${KONTAKTART_EINZAHL[art]}`
- : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
- )
-
- absaetze.push(
- `Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
- `${aufzaehlung(teile)}.`,
- )
-
- const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
- const luecken = mitIhr
- .slice(1)
- .map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
- const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
- const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
- if (groessteLuecke > 0) {
- rhythmus.push(
- `Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
- )
- }
- rhythmus.push(
- `Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
- `der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
- )
- absaetze.push(rhythmus.join(' '))
-
- const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
- if (minuten > 0) {
- absaetze.push(
- `Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
- `wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
- `das Befinden und die persönliche Lage von ${name}.`,
- )
- }
- }
-
- if (mitDritten.length > 0) {
- const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
- const teile = [...nachArt.entries()]
- .sort((a, b) => b[1] - a[1])
- .map(([art, anzahl]) =>
- anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
- )
- const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
- const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
- absaetze.push(
- `Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
- `${aufzaehlung(teile)}.${wer}`,
- )
- }
-
- return absaetze
-}
-
-function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
- const { betreute, zeitraumVon, zeitraumBis } = quellen
- const name = kurzName(betreute)
- const absaetze: string[] = []
-
- absaetze.push(
- `Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
- `${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
- `festgehalten.`,
- )
-
- const mitWunsch = quellen.kontakte
- .filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
- .filter((k) => (k.wuensche ?? '').trim() !== '')
- .sort((a, b) => a.datum.getTime() - b.datum.getTime())
-
- if (mitWunsch.length === 0) {
- absaetze.push(
- `Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
- `${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
- `Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
- )
- } else {
- absaetze.push(
- `Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
- )
- for (const kontakt of mitWunsch) {
- absaetze.push(
- `• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
- `${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
- )
- }
- absaetze.push(
- `Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
- `entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
- )
- }
-
- if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
- absaetze.push(
- `Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
- `${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
- )
- }
-
- return absaetze
-}
-
-function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
- const { zeitraumVon, zeitraumBis } = quellen
- const absaetze: string[] = []
-
- if (quellen.vorgaenge.length === 0) {
- absaetze.push(
- `Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
- `hast du bearbeitet?`,
- )
- return absaetze
- }
-
- const erledigt = quellen.vorgaenge.filter(
- (v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
- )
- const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
-
- absaetze.push(
- `Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
- `${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
- )
-
- const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
- KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
- )
-
- for (const kategorie of kategorien) {
- const fertig = erledigt.filter((v) => v.kategorie === kategorie)
- const offen = laufend.filter((v) => v.kategorie === kategorie)
- const saetze: string[] = []
-
- if (fertig.length > 0) {
- const titel = fertig.map((v) =>
- v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
- )
- saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
- }
- if (offen.length > 0) {
- const titel = offen.map((v) => {
- const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
- const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
- return `${v.titel}${wer}${schritt}`
- })
- saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
- }
-
- absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
- }
-
- const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
- if (genehmigungspflichtig.length > 0) {
- absaetze.push(
- `Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
- `Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
- )
- }
-
- return absaetze
-}
-
-function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
- const { betreute } = quellen
- const name = kurzName(betreute)
- const absaetze: string[] = []
-
- const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
- if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
- kreise.push(betreute.aufgabenkreiseFreitext.trim())
- }
-
- absaetze.push(
- kreise.length > 0
- ? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
- : `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
- )
-
- const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
- const begruendung =
- laufend.length > 0
- ? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
- `${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
- `ist daher nicht erkennbar.`
- : `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
-
- absaetze.push(
- `Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
- `fortzuführen. ${begruendung}`,
- )
-
- absaetze.push(
- `Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
- `einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
- `${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
- `einzuschränken oder aufzuheben.`,
- )
-
- absaetze.push(
- `${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
- `falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
- )
-
- return absaetze
-}
-
-// -----------------------------------------------------------------------------
-// Zusammenbau
-// -----------------------------------------------------------------------------
-
-/**
- * Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
- * gleicher Text - damit sich der Entwurf testen laesst.
- */
-export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
- const bauer = [
- abschnittPersoenlicheVerhaeltnisse,
- abschnittKontakt,
- abschnittWuensche,
- abschnittAngelegenheiten,
- abschnittEinschaetzung,
- ]
-
- return GLIEDERUNG.map((eintrag, i) => ({
- nummer: eintrag.nummer,
- titel: eintrag.titel,
- rechtsgrundlage: eintrag.rechtsgrundlage,
- absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
- }))
-}
-
-/**
- * Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
- * abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
- * damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
- */
-export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
- return abschnitte
- .map((abschnitt) =>
- [`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
- )
- .join('\n\n')
- .trim()
-}
-
-/** Direktweg von den Quellen zum Text. */
-export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
- return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
-}
-
-/**
- * Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
- * ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
- * nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
- * auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
- */
-export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
- const zeilen = text.replace(/\r\n/g, '\n').split('\n')
- const abschnitte: { titel: string | null; absaetze: string[] }[] = []
- let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
-
- for (const zeile of zeilen) {
- if (/^\s*\d+\.\s+\S/.test(zeile)) {
- if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
- aktuell = { titel: zeile.trim(), absaetze: [] }
- continue
- }
- if (zeile.trim() === '') continue
- aktuell.absaetze.push(zeile.trim())
- }
- if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
-
- return abschnitte
-}
-
-/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
-export function offeneErgaenzungen(text: string): number {
- return text.split(ERGAENZEN).length - 1
-}
+export * from '@/lib/berichte/jahresbericht.entwurf'
+export * from '@/lib/berichte/jahresbericht.inhalt'
+export * from '@/lib/berichte/jahresbericht.ausgabe'
diff --git a/src/lib/berichte/jahresbericht.tsx b/src/lib/berichte/jahresbericht.tsx
index e73900f..f7611e8 100644
--- a/src/lib/berichte/jahresbericht.tsx
+++ b/src/lib/berichte/jahresbericht.tsx
@@ -1,234 +1,25 @@
-import 'server-only'
-import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
-import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
-import { pdfErzeugen } from '@/lib/pdf/erzeugen'
-import { db, nichtGeloescht } from '@/lib/db'
-import { oeffnen } from '@/lib/crypto/feld'
-// Die Endung steht hier absichtlich: Neben dieser Datei liegt
-// jahresbericht.ts mit den reinen Textbausteinen. Ein Import ohne Endung
-// waere mehrdeutig - Webpack probiert ".tsx" vor ".ts" und wuerde diese
-// Datei sich selbst importieren, TypeScript und Vitest wuerden die ".ts"
-// nehmen. Mit ausgeschriebener Endung greifen alle drei dieselbe Datei.
-// Damit trotzdem jeder Aufrufer alles unter einem Namen findet, wird die
-// reine Schnittstelle unten vollstaendig weitergereicht.
-import {
- jahresberichtEntwurfText,
- textZuAbschnitten,
- type JahresberichtQuellen,
-} from './jahresbericht.ts'
-
-export * from './jahresbericht.ts'
-
/**
- * Jahresbericht - Datenbeschaffung und PDF.
+ * Jahresbericht nach § 1863 BGB - gemeinsame Anlaufstelle (Zwilling).
*
- * Die Textbausteine stehen in jahresbericht.ts und kommen ohne Datenbank
- * aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins PDF geht,
- * ist IMMER der gespeicherte, vom Betreuer ueberarbeitete Entwurfstext -
- * nie ein frisch berechneter. Sonst stuende im abgegebenen Bericht etwas
- * anderes als das, was auf dem Bildschirm zur Freigabe stand.
+ * Der Bericht besteht aus drei Bausteinen:
+ *
+ * jahresbericht.entwurf.ts Textbausteine, rein, ohne Datenbank
+ * jahresbericht.inhalt.tsx Satz fuer das PDF (React)
+ * jahresbericht.ausgabe.ts Daten laden und PDF erzeugen
+ *
+ * Warum diese Aufteilung, und warum diese Datei? Neben ihr liegt eine
+ * gleichnamige .tsx. Webpack probiert beim Import ohne Endung ".tsx" vor
+ * ".ts", TypeScript und Vitest nehmen die ".ts" - derselbe Importpfad
+ * waere je nach Werkzeug eine andere Datei. Deshalb tragen die Bausteine
+ * eindeutige Namen, und die beiden gleichnamigen Dateien reichen exakt
+ * dieselben Namen weiter. Wer "@/lib/berichte/jahresbericht" importiert,
+ * bekommt so immer dieselbe Schnittstelle.
+ *
+ * Wer nur die Textbausteine braucht - Tests, die DOCX-Ausgabe -, importiert
+ * besser direkt "@/lib/berichte/jahresbericht.entwurf": das spart Datenbank
+ * und PDF-Bibliothek.
*/
-// -----------------------------------------------------------------------------
-// Quelldaten aus der Datenbank
-// -----------------------------------------------------------------------------
-
-/**
- * Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
- * Zugriffsberechtigung bereits geprueft (erfordereZugang) - diese Funktion
- * fragt nicht noch einmal, sie eng auf die betreuteId zu begrenzen ist
- * aber Pflicht.
- */
-export async function jahresberichtQuellenLaden(
- betreuteId: string,
- zeitraumVon: Date,
- zeitraumBis: Date,
-): Promise {
- const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
- db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
- db.kontaktprotokoll.findMany({
- where: {
- betreuteId,
- datum: { gte: zeitraumVon, lte: zeitraumBis },
- ...nichtGeloescht,
- },
- orderBy: { datum: 'asc' },
- }),
- db.gesundheitsereignis.findMany({
- where: {
- betreuteId,
- datum: { gte: zeitraumVon, lte: zeitraumBis },
- ...nichtGeloescht,
- },
- orderBy: { datum: 'asc' },
- }),
- db.vorgang.findMany({
- where: {
- betreuteId,
- ...nichtGeloescht,
- // Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
- // Bericht des Vorjahres - hier waere es nur Ballast.
- OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
- },
- orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
- }),
- ])
-
- const betreute = oeffnen('Betreute', roheBetreute)
-
- return {
- betreute: {
- anrede: betreute.anrede,
- vorname: betreute.vorname,
- nachname: betreute.nachname,
- geburtsdatum: betreute.geburtsdatum,
- heimName: betreute.heimName,
- heimStrasse: betreute.heimStrasse,
- heimPlz: betreute.heimPlz,
- heimOrt: betreute.heimOrt,
- heimZimmer: betreute.heimZimmer,
- heimEinzugAm: betreute.heimEinzugAm,
- fruehereStrasse: betreute.fruehereStrasse,
- frueherePlz: betreute.frueherePlz,
- fruehererOrt: betreute.fruehererOrt,
- pflegegrad: betreute.pflegegrad,
- pflegegradSeit: betreute.pflegegradSeit,
- schwerbehinderungGdB: betreute.schwerbehinderungGdB,
- schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
- diagnosen: betreute.diagnosen,
- ermittelterWille: betreute.ermittelterWille,
- patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
- vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
- aufgabenkreise: betreute.aufgabenkreise,
- aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
- betreuungsgericht: betreute.betreuungsgericht,
- aktenzeichenGericht: betreute.aktenzeichenGericht,
- bestellungAm: betreute.bestellungAm,
- bestellungBis: betreute.bestellungBis,
- },
- zeitraumVon,
- zeitraumBis,
- kontakte: kontakte.map((k) => {
- const offen = oeffnen('Kontaktprotokoll', k)
- return {
- datum: k.datum,
- art: k.art,
- mitBetreuter: k.mitBetreuter,
- dauerMinuten: k.dauerMinuten,
- gespraechspartner: k.gespraechspartner,
- wuensche: offen.wuensche,
- }
- }),
- gesundheit: gesundheit.map((e) => ({
- datum: e.datum,
- bisDatum: e.bisDatum,
- art: e.art,
- titel: e.titel,
- einrichtung: e.einrichtung,
- abwesenheitstage: e.abwesenheitstage,
- })),
- vorgaenge: vorgaenge.map((v) => ({
- titel: v.titel,
- kategorie: v.kategorie,
- status: v.status,
- naechsterSchritt: v.naechsterSchritt,
- gegenstelle: v.gegenstelle,
- genehmigungspflichtig: v.genehmigungspflichtig,
- erledigtAm: v.erledigtAm,
- })),
- }
-}
-
-/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu berechnen". */
-export async function jahresberichtEntwurfAusDatenbank(
- betreuteId: string,
- zeitraumVon: Date,
- zeitraumBis: Date,
-): Promise {
- return jahresberichtEntwurfText(
- await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
- )
-}
-
-// -----------------------------------------------------------------------------
-// PDF
-// -----------------------------------------------------------------------------
-
-const stile = StyleSheet.create({
- ueberschrift: {
- fontSize: 12,
- fontFamily: 'Helvetica-Bold',
- marginTop: 14,
- marginBottom: 4,
- },
- absatz: {
- fontSize: 10,
- lineHeight: 1.5,
- marginBottom: 6,
- textAlign: 'justify',
- },
- aufzaehlung: {
- fontSize: 10,
- lineHeight: 1.5,
- marginBottom: 3,
- marginLeft: 12,
- },
- vorspann: {
- fontSize: 9,
- lineHeight: 1.4,
- marginBottom: 10,
- color: '#444444',
- },
-})
-
-/**
- * Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
- * fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
- * Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
- * das sich im Textfeld auch von Hand fortschreiben laesst.
- */
-export function JahresberichtInhalt({ text }: { text: string }) {
- const abschnitte = textZuAbschnitten(text)
-
- return (
-
- {abschnitte.map((abschnitt, i) => (
-
- {abschnitt.titel && {abschnitt.titel} }
- {abschnitt.absaetze.map((absatz, j) => (
-
- {absatz}
-
- ))}
-
- ))}
-
- )
-}
-
-export function JahresberichtDokument({
- kopf,
- text,
- vorspann,
-}: {
- kopf: Berichtskopf
- text: string
- vorspann?: string
-}) {
- return (
-
-
- {vorspann && {vorspann} }
-
-
-
- )
-}
-
-/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
-export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise {
- return pdfErzeugen( )
-}
+export * from '@/lib/berichte/jahresbericht.entwurf'
+export * from '@/lib/berichte/jahresbericht.inhalt'
+export * from '@/lib/berichte/jahresbericht.ausgabe'
diff --git a/src/server/actions/berichte.ts b/src/server/actions/berichte.ts
index 5a10a70..47cf6b9 100644
--- a/src/server/actions/berichte.ts
+++ b/src/server/actions/berichte.ts
@@ -1,638 +1,612 @@
-'use server'
-
-import { revalidatePath } from 'next/cache'
-import type { BerichtArt } from '@prisma/client'
-import { db, nichtGeloescht } from '@/lib/db'
-import { protokollieren } from '@/lib/audit'
-import { sealen, entschluesselnWennMoeglich } from '@/lib/crypto/feld'
-import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
-import { ablegen } from '@/lib/speicher'
-import { belegmappeErzeugen } from '@/lib/dokumente/belegmappe'
-import type { Berichtskopf } from '@/lib/pdf/rahmen'
-import {
- jahresberichtEntwurfAusDatenbank,
- jahresberichtPdf,
-} from '@/lib/berichte/jahresbericht.tsx'
-import { docxErzeugen } from '@/lib/berichte/docx'
-import { unterlagenpaketErzeugen } from '@/lib/berichte/unterlagenpaket'
-import {
- belegmappeSchema,
- berichtAbgabeSchema,
- berichtDateiname,
- entwurfSpeichernSchema,
- jahresberichtErzeugenSchema,
- unterlagenpaketSchema,
-} from '@/lib/validierung/berichte'
-
-/**
- * Server Actions rund um die Berichte.
- *
- * Zwei Regeln ziehen sich durch alle: Erstens wird jeder Zugriff
- * serverseitig gegen die betreuteId geprueft, auch wenn die Seite den
- * Knopf gar nicht erst angezeigt haette. Zweitens entsteht zu jedem
- * erzeugten Bericht ein Bericht-Datensatz - die Frage "was habe ich wann
- * in welcher Fassung abgegeben?" muss sich Jahre spaeter beantworten
- * lassen, auch gegenueber Miterben.
- */
-
-export type Aktionszustand = {
- fehler?: string
- felder?: Record
- erfolg?: string
- berichtId?: string
-}
-
-function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record {
- const felder: Record = {}
- for (const problem of fehler) {
- const schluessel = String(problem.path[0] ?? 'formular')
- if (!felder[schluessel]) felder[schluessel] = problem.message
- }
- return felder
-}
-
-async function kopfBauen(
- betreuteId: string,
- titel: string,
- angaben: {
- zeitraumVon?: Date | null
- zeitraumBis?: Date | null
- stichtag?: Date | null
- rechtsgrundlage?: string
- erstelltVon: string
- erstelltAm: Date
- },
-): Promise {
- const betreute = await db.betreute.findFirstOrThrow({
- where: { id: betreuteId, ...nichtGeloescht },
- select: {
- anrede: true,
- vorname: true,
- nachname: true,
- geburtsdatum: true,
- betreuungsgericht: true,
- aktenzeichenGericht: true,
- },
- })
-
- return {
- titel,
- betreute: {
- name: [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' '),
- geburtsdatum: betreute.geburtsdatum,
- },
- behoerde: betreute.betreuungsgericht ?? undefined,
- aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
- zeitraumVon: angaben.zeitraumVon ?? null,
- zeitraumBis: angaben.zeitraumBis ?? null,
- stichtag: angaben.stichtag ?? null,
- rechtsgrundlage: angaben.rechtsgrundlage,
- erstelltAm: angaben.erstelltAm,
- erstelltVon: angaben.erstelltVon,
- }
-}
-
-// -----------------------------------------------------------------------------
-// Jahresbericht
-// -----------------------------------------------------------------------------
-
-/**
- * Legt einen Jahresbericht an und fuellt ihn mit dem Textentwurf. Gibt es
- * fuer denselben Zeitraum schon einen, wird er zurueckgegeben statt eines
- * zweiten - sonst haette man am Ende drei Fassungen und wuesste nicht mehr,
- * welche abgegeben wurde.
- */
-export async function jahresberichtAnlegen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = jahresberichtErzeugenSchema.safeParse({
- betreuteId: formular.get('betreuteId'),
- zeitraumVon: formular.get('zeitraumVon'),
- zeitraumBis: formular.get('zeitraumBis'),
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const { betreuteId, zeitraumVon, zeitraumBis } = gelesen.data
- const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') {
- return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
- }
-
- const vorhanden = await db.bericht.findFirst({
- where: { betreuteId, art: 'JAHRESBERICHT', zeitraumVon, zeitraumBis, ...nichtGeloescht },
- })
- if (vorhanden) {
- return {
- berichtId: vorhanden.id,
- erfolg: 'Für diesen Zeitraum gibt es bereits einen Entwurf. Er wird jetzt geöffnet.',
- }
- }
-
- const entwurf = await jahresberichtEntwurfAusDatenbank(betreuteId, zeitraumVon, zeitraumBis)
-
- const bericht = await db.bericht.create({
- data: {
- betreuteId,
- art: 'JAHRESBERICHT',
- titel: `Jahresbericht ${zeitraumVon.getUTCFullYear()}/${zeitraumBis.getUTCFullYear()}`,
- zeitraumVon,
- zeitraumBis,
- erstelltVonId: benutzer.id,
- ...sealen('Bericht', { entwurf }),
- },
- })
-
- await protokollieren({
- aktion: 'ERSTELLT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: bericht.titel,
- betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/berichte')
- return { berichtId: bericht.id, erfolg: 'Der Entwurf steht. Jetzt bist du dran: durchlesen und überarbeiten.' }
-}
-
-/** Zwischenspeichern des ueberarbeiteten Textes. */
-export async function entwurfSpeichern(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = entwurfSpeichernSchema.safeParse({
- berichtId: formular.get('berichtId'),
- entwurf: formular.get('entwurf'),
- })
- if (!gelesen.success) {
- return { fehler: 'Der Entwurf konnte nicht gespeichert werden.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const bericht = await db.bericht.findFirst({
- where: { id: gelesen.data.berichtId, ...nichtGeloescht },
- select: { id: true, betreuteId: true, titel: true },
- })
- if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
-
- const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts speichern.' }
-
- await db.bericht.update({
- where: { id: bericht.id },
- data: sealen('Bericht', { entwurf: gelesen.data.entwurf }),
- })
-
- await protokollieren({
- aktion: 'GEAENDERT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: bericht.titel,
- betreuteId: bericht.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- aenderung: { entwurf: { alt: '(überarbeitet)', neu: `${gelesen.data.entwurf.length} Zeichen` } },
- })
-
- revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
- return { erfolg: `Zwischengespeichert um ${new Date().toLocaleTimeString('de-DE')}.` }
-}
-
-/**
- * Berechnet den Entwurf aus den aktuellen Daten neu. Der bisherige Text
- * wird dabei ersetzt - deshalb fragt die Oberflaeche vorher nach.
- */
-export async function entwurfNeuBerechnen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const berichtId = String(formular.get('berichtId') ?? '')
- const bericht = await db.bericht.findFirst({
- where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
- })
- if (!bericht || !bericht.zeitraumVon || !bericht.zeitraumBis) {
- return { fehler: 'Zu diesem Bericht fehlt der Zeitraum.' }
- }
-
- const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts ändern.' }
-
- const entwurf = await jahresberichtEntwurfAusDatenbank(
- bericht.betreuteId,
- bericht.zeitraumVon,
- bericht.zeitraumBis,
- )
- await db.bericht.update({ where: { id: bericht.id }, data: sealen('Bericht', { entwurf }) })
-
- await protokollieren({
- aktion: 'GEAENDERT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: `${bericht.titel} (Entwurf neu berechnet)`,
- betreuteId: bericht.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
- return { erfolg: 'Der Entwurf wurde aus den aktuellen Daten neu aufgebaut.' }
-}
-
-/** Erzeugt PDF oder DOCX aus dem gespeicherten Entwurfstext. */
-export async function jahresberichtAusgeben(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const berichtId = String(formular.get('berichtId') ?? '')
- const format = String(formular.get('format') ?? 'pdf') === 'docx' ? 'docx' : 'pdf'
-
- const bericht = await db.bericht.findFirst({
- where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
- })
- if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
-
- const { benutzer, betreute, zugang } = await erfordereZugang(
- bericht.betreuteId,
- 'BERICHTE',
- 'schreiben',
- )
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
-
- const entwurf = entschluesselnWennMoeglich(bericht.entwurfVerschluesselt, 'entwurfVerschluesselt')
- if (!entwurf || entwurf.trim() === '') {
- return { fehler: 'Der Entwurf ist leer. Schreib erst den Text, dann die Datei.' }
- }
-
- const erstelltAm = new Date()
- const kopf = await kopfBauen(bericht.betreuteId, bericht.titel, {
- zeitraumVon: bericht.zeitraumVon,
- zeitraumBis: bericht.zeitraumBis,
- rechtsgrundlage: '§ 1863 Abs. 3 BGB',
- erstelltVon: benutzer.name,
- erstelltAm,
- })
-
- const inhalt =
- format === 'docx' ? await docxErzeugen(kopf, entwurf) : await jahresberichtPdf(kopf, entwurf)
- const dateiname = berichtDateiname('jahresbericht', betreute.nachname, erstelltAm, format)
- const mimeTyp =
- format === 'docx'
- ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
- : 'application/pdf'
-
- const ziel = await ablegen(bericht.betreuteId, 'berichte', dateiname, inhalt, mimeTyp)
-
- // Die PDF-Fassung ist die abzugebende und wird am Bericht vermerkt; die
- // DOCX ist Arbeitsfassung und bekommt einen eigenen Datensatz, damit die
- // abgegebene Fassung nicht ueberschrieben wird.
- if (format === 'pdf') {
- await db.bericht.update({
- where: { id: bericht.id },
- data: { objektSchluessel: ziel.schluessel, dateiname, pruefsumme: ziel.pruefsumme },
- })
- }
-
- await protokollieren({
- aktion: 'ERSTELLT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: `${bericht.titel} als ${format.toUpperCase()}`,
- betreuteId: bericht.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
- revalidatePath('/berichte')
- return {
- erfolg:
- format === 'docx'
- ? 'Die Word-Fassung liegt bereit. Sie ist die Arbeitsfassung – abgegeben wird das PDF.'
- : 'Das PDF liegt bereit. Prüfe es, bevor du es abgibst.',
- }
-}
-
-/** Haelt fest, wann und wem der Bericht vorgelegt wurde. */
-export async function abgabeVermerken(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = berichtAbgabeSchema.safeParse({
- berichtId: formular.get('berichtId'),
- abgegebenAm: formular.get('abgegebenAm'),
- abgegebenAn: formular.get('abgegebenAn'),
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const bericht = await db.bericht.findFirst({
- where: { id: gelesen.data.berichtId, ...nichtGeloescht },
- select: { id: true, betreuteId: true, titel: true },
- })
- if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
-
- const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts vermerken.' }
-
- await db.bericht.update({
- where: { id: bericht.id },
- data: { abgegebenAm: gelesen.data.abgegebenAm, abgegebenAn: gelesen.data.abgegebenAn },
- })
-
- await protokollieren({
- aktion: 'GEAENDERT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: `${bericht.titel} abgegeben an ${gelesen.data.abgegebenAn}`,
- betreuteId: bericht.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/berichte')
- return { erfolg: 'Abgabe vermerkt.' }
-}
-
-// -----------------------------------------------------------------------------
-// Belegmappe
-// -----------------------------------------------------------------------------
-
-/**
- * Belegmappe fuer einen Zeitraum oder einen Vorgang. Das Zusammenstellen
- * selbst liegt beim Dokumentenmodul - dort sitzt das Wissen darueber, wie
- * ein Beleg zu einer Buchung gehoert.
- */
-export async function belegmappeAnlegen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = belegmappeSchema.safeParse({
- betreuteId: formular.get('betreuteId'),
- quelle: formular.get('quelle'),
- zeitraumVon: formular.get('zeitraumVon') ?? '',
- zeitraumBis: formular.get('zeitraumBis') ?? '',
- vorgangId: formular.get('vorgangId') ?? '',
- titel: formular.get('titel') ?? '',
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const { betreuteId, quelle, zeitraumVon, zeitraumBis, vorgangId, titel } = gelesen.data
- const { benutzer, betreute, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Belegmappe erzeugen.' }
-
- const erstelltAm = new Date()
-
- let ergebnis: { pdf: Buffer; dateiname: string; anzahlBelege: number }
- try {
- ergebnis = await belegmappeErzeugen({
- betreuteId,
- zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
- zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
- vorgangId: quelle === 'vorgang' ? (vorgangId ?? null) : null,
- titel: titel && titel !== '' ? titel : 'Belegmappe',
- erstelltVon: benutzer.name,
- erstelltAm,
- })
- } catch (fehler) {
- return {
- fehler:
- 'Die Belegmappe konnte nicht erzeugt werden: ' +
- `${(fehler as Error).message}. Prüfe, ob zu dieser Auswahl überhaupt Belege hinterlegt sind.`,
- }
- }
-
- if (ergebnis.anzahlBelege === 0) {
- return { fehler: 'Zu dieser Auswahl gibt es keine Belege. Wähle einen anderen Zeitraum oder Vorgang.' }
- }
-
- const ziel = await ablegen(betreuteId, 'berichte', ergebnis.dateiname, ergebnis.pdf, 'application/pdf')
-
- const bericht = await db.bericht.create({
- data: {
- betreuteId,
- art: 'BELEGMAPPE',
- titel:
- titel && titel !== ''
- ? titel
- : quelle === 'zeitraum'
- ? `Belegmappe ${zeitraumVon?.getUTCFullYear() ?? ''}`
- : 'Belegmappe zum Vorgang',
- zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
- zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
- objektSchluessel: ziel.schluessel,
- dateiname: ergebnis.dateiname,
- pruefsumme: ziel.pruefsumme,
- erstelltVonId: benutzer.id,
- },
- })
-
- await protokollieren({
- aktion: 'ERSTELLT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: `${bericht.titel} (${ergebnis.anzahlBelege} Belege, ${betreute.nachname})`,
- betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/berichte')
- return { berichtId: bericht.id, erfolg: `Belegmappe mit ${ergebnis.anzahlBelege} Belegen erzeugt.` }
-}
-
-// -----------------------------------------------------------------------------
-// Unterlagenpaket
-// -----------------------------------------------------------------------------
-
-export async function unterlagenpaketAnlegen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = unterlagenpaketSchema.safeParse({
- betreuteId: formular.get('betreuteId'),
- antragId: formular.get('antragId'),
- nachforderungIds: formular.getAll('nachforderungIds').map(String),
- anschreibenZusatz: formular.get('anschreibenZusatz') ?? '',
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const { betreuteId, antragId, nachforderungIds, anschreibenZusatz } = gelesen.data
- const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du kein Paket erzeugen.' }
- // Die Unterlagen stammen aus der Sozialhilfeakte - auch dort muss gelesen
- // werden duerfen.
- await erfordereZugang(betreuteId, 'SOZIALHILFE', 'lesen')
-
- const erstelltAm = new Date()
- const ergebnis = await unterlagenpaketErzeugen({
- betreuteId,
- antragId,
- nachforderungIds,
- anschreibenZusatz,
- erstelltVon: benutzer.name,
- erstelltAm,
- })
-
- const ziel = await ablegen(
- betreuteId,
- 'berichte',
- ergebnis.dateiname,
- ergebnis.pdf,
- 'application/pdf',
- )
-
- const bericht = await db.bericht.create({
- data: {
- betreuteId,
- art: 'UNTERLAGENPAKET_BEZIRK',
- titel: `Unterlagen an den Träger (${ergebnis.aufstellung.length} Positionen)`,
- stichtag: erstelltAm,
- objektSchluessel: ziel.schluessel,
- dateiname: ergebnis.dateiname,
- pruefsumme: ziel.pruefsumme,
- erstelltVonId: benutzer.id,
- },
- })
-
- // Positionen, die tatsaechlich mitgehen, gelten als uebersandt.
- const uebersandt = ergebnis.aufstellung.filter((a) => a.beigefuegt).map((a) => a.unterlage.id)
- if (uebersandt.length > 0) {
- await db.nachforderung.updateMany({
- where: { id: { in: uebersandt }, antragId },
- data: { status: 'UEBERSANDT', uebersandtAm: erstelltAm },
- })
- }
-
- await protokollieren({
- aktion: 'ERSTELLT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: bericht.titel,
- betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/berichte')
- revalidatePath('/sozialhilfe')
-
- const uebersprungen =
- ergebnis.uebersprungen.length > 0
- ? ` Nicht lesbar und deshalb nicht enthalten: ${ergebnis.uebersprungen
- .map((u) => u.bezeichnung)
- .join(', ')}.`
- : ''
-
- return {
- berichtId: bericht.id,
- erfolg: `Das Paket ist fertig: ${uebersandt.length} beigefügte Unterlagen.${uebersprungen}`,
- }
-}
-
-// -----------------------------------------------------------------------------
-// Berichte loeschen (Papierkorb)
-// -----------------------------------------------------------------------------
-
-export async function berichtLoeschen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const berichtId = String(formular.get('berichtId') ?? '')
- const bericht = await db.bericht.findFirst({
- where: { id: berichtId, ...nichtGeloescht },
- select: { id: true, betreuteId: true, titel: true, art: true },
- })
- if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
-
- const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
-
- await db.bericht.update({ where: { id: bericht.id }, data: { geloeschtAm: new Date() } })
-
- await protokollieren({
- aktion: 'GELOESCHT',
- entitaet: 'Bericht',
- entitaetId: bericht.id,
- bezeichnung: bericht.titel,
- betreuteId: bericht.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/berichte')
- return { erfolg: 'In den Papierkorb verschoben. Dort liegt er 30 Tage.' }
-}
-
-// -----------------------------------------------------------------------------
-// Vollexport
-// -----------------------------------------------------------------------------
-
-/**
- * Der Export selbst laeuft ueber die Route unter /berichte/export/datei,
- * weil ein ZIP als Strom ausgeliefert wird und eine Server Action keinen
- * Strom zurueckgeben kann. Diese Aktion sorgt fuer die frische Anmeldung
- * und schreibt Protokoll und Bericht-Datensatz - damit spaeter nachweisbar
- * ist, wann Daten das Haus verlassen haben.
- */
-export async function exportVorbereiten(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const betreuteId = String(formular.get('betreuteId') ?? '')
- const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
-
- if (zugang.rolle !== 'BETREUER' && zugang.rolle !== 'ADMIN') {
- return { fehler: 'Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.' }
- }
-
- await erfordereFrischeAnmeldung(benutzer, '/berichte/export')
- return { erfolg: 'Bestätigt. Der Export kann jetzt geladen werden.' }
-}
-
-/** Vermerkt einen erfolgten Export - aufgerufen von der Downloadroute. */
-export async function exportProtokollieren(angaben: {
- betreuteId: string
- dateiname: string
- art?: BerichtArt
-}): Promise {
- const { benutzer } = await erfordereZugang(angaben.betreuteId, 'BERICHTE', 'schreiben')
-
- const bericht = await db.bericht.create({
- data: {
- betreuteId: angaben.betreuteId,
- art: angaben.art ?? 'VOLLEXPORT',
- titel: 'Vollexport aller Daten',
- stichtag: new Date(),
- dateiname: angaben.dateiname,
- erstelltVonId: benutzer.id,
- },
- })
-
- await protokollieren({
- aktion: 'EXPORTIERT',
- entitaet: 'Betreute',
- entitaetId: angaben.betreuteId,
- bezeichnung: `Vollexport ${angaben.dateiname}`,
- betreuteId: angaben.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- aenderung: { export: { alt: null, neu: bericht.id } },
- })
-}
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { db, nichtGeloescht } from '@/lib/db'
+import { protokollieren } from '@/lib/audit'
+import { sealen, entschluesselnWennMoeglich } from '@/lib/crypto/feld'
+import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
+import { ablegen } from '@/lib/speicher'
+import {
+ belegmappeErzeugen,
+ type Belegmappenauswahl,
+ type Mappenergebnis,
+} from '@/lib/dokumente/belegmappe'
+import type { Berichtskopf } from '@/lib/pdf/rahmen'
+import {
+ jahresberichtEntwurfAusDatenbank,
+ jahresberichtPdf,
+} from '@/lib/berichte/jahresbericht'
+import { docxErzeugen } from '@/lib/berichte/docx'
+import { unterlagenpaketErzeugen } from '@/lib/berichte/unterlagenpaket'
+import {
+ belegmappeSchema,
+ berichtAbgabeSchema,
+ berichtDateiname,
+ entwurfSpeichernSchema,
+ jahresberichtErzeugenSchema,
+ unterlagenpaketSchema,
+} from '@/lib/validierung/berichte'
+
+/**
+ * Server Actions rund um die Berichte.
+ *
+ * Zwei Regeln ziehen sich durch alle: Erstens wird jeder Zugriff
+ * serverseitig gegen die betreuteId geprueft, auch wenn die Seite den
+ * Knopf gar nicht erst angezeigt haette. Zweitens entsteht zu jedem
+ * erzeugten Bericht ein Bericht-Datensatz - die Frage "was habe ich wann
+ * in welcher Fassung abgegeben?" muss sich Jahre spaeter beantworten
+ * lassen, auch gegenueber Miterben.
+ */
+
+export type Aktionszustand = {
+ fehler?: string
+ felder?: Record
+ erfolg?: string
+ berichtId?: string
+}
+
+function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record {
+ const felder: Record = {}
+ for (const problem of fehler) {
+ const schluessel = String(problem.path[0] ?? 'formular')
+ if (!felder[schluessel]) felder[schluessel] = problem.message
+ }
+ return felder
+}
+
+async function kopfBauen(
+ betreuteId: string,
+ titel: string,
+ angaben: {
+ zeitraumVon?: Date | null
+ zeitraumBis?: Date | null
+ stichtag?: Date | null
+ rechtsgrundlage?: string
+ erstelltVon: string
+ erstelltAm: Date
+ },
+): Promise {
+ const betreute = await db.betreute.findFirstOrThrow({
+ where: { id: betreuteId, ...nichtGeloescht },
+ select: {
+ anrede: true,
+ vorname: true,
+ nachname: true,
+ geburtsdatum: true,
+ betreuungsgericht: true,
+ aktenzeichenGericht: true,
+ },
+ })
+
+ return {
+ titel,
+ betreute: {
+ name: [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' '),
+ geburtsdatum: betreute.geburtsdatum,
+ },
+ behoerde: betreute.betreuungsgericht ?? undefined,
+ aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
+ zeitraumVon: angaben.zeitraumVon ?? null,
+ zeitraumBis: angaben.zeitraumBis ?? null,
+ stichtag: angaben.stichtag ?? null,
+ rechtsgrundlage: angaben.rechtsgrundlage,
+ erstelltAm: angaben.erstelltAm,
+ erstelltVon: angaben.erstelltVon,
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Jahresbericht
+// -----------------------------------------------------------------------------
+
+/**
+ * Legt einen Jahresbericht an und fuellt ihn mit dem Textentwurf. Gibt es
+ * fuer denselben Zeitraum schon einen, wird er zurueckgegeben statt eines
+ * zweiten - sonst haette man am Ende drei Fassungen und wuesste nicht mehr,
+ * welche abgegeben wurde.
+ */
+export async function jahresberichtAnlegen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = jahresberichtErzeugenSchema.safeParse({
+ betreuteId: formular.get('betreuteId'),
+ zeitraumVon: formular.get('zeitraumVon'),
+ zeitraumBis: formular.get('zeitraumBis'),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const { betreuteId, zeitraumVon, zeitraumBis } = gelesen.data
+ const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') {
+ return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
+ }
+
+ const vorhanden = await db.bericht.findFirst({
+ where: { betreuteId, art: 'JAHRESBERICHT', zeitraumVon, zeitraumBis, ...nichtGeloescht },
+ })
+ if (vorhanden) {
+ return {
+ berichtId: vorhanden.id,
+ erfolg: 'Für diesen Zeitraum gibt es bereits einen Entwurf. Er wird jetzt geöffnet.',
+ }
+ }
+
+ const entwurf = await jahresberichtEntwurfAusDatenbank(betreuteId, zeitraumVon, zeitraumBis)
+
+ const bericht = await db.bericht.create({
+ data: {
+ betreuteId,
+ art: 'JAHRESBERICHT',
+ titel: `Jahresbericht ${zeitraumVon.getUTCFullYear()}/${zeitraumBis.getUTCFullYear()}`,
+ zeitraumVon,
+ zeitraumBis,
+ erstelltVonId: benutzer.id,
+ ...sealen('Bericht', { entwurf }),
+ },
+ })
+
+ await protokollieren({
+ aktion: 'ERSTELLT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: bericht.titel,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/berichte')
+ return { berichtId: bericht.id, erfolg: 'Der Entwurf steht. Jetzt bist du dran: durchlesen und überarbeiten.' }
+}
+
+/** Zwischenspeichern des ueberarbeiteten Textes. */
+export async function entwurfSpeichern(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = entwurfSpeichernSchema.safeParse({
+ berichtId: formular.get('berichtId'),
+ entwurf: formular.get('entwurf'),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Der Entwurf konnte nicht gespeichert werden.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const bericht = await db.bericht.findFirst({
+ where: { id: gelesen.data.berichtId, ...nichtGeloescht },
+ select: { id: true, betreuteId: true, titel: true },
+ })
+ if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
+
+ const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts speichern.' }
+
+ await db.bericht.update({
+ where: { id: bericht.id },
+ data: sealen('Bericht', { entwurf: gelesen.data.entwurf }),
+ })
+
+ await protokollieren({
+ aktion: 'GEAENDERT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: bericht.titel,
+ betreuteId: bericht.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ aenderung: { entwurf: { alt: '(überarbeitet)', neu: `${gelesen.data.entwurf.length} Zeichen` } },
+ })
+
+ revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
+ return { erfolg: `Zwischengespeichert um ${new Date().toLocaleTimeString('de-DE')}.` }
+}
+
+/**
+ * Berechnet den Entwurf aus den aktuellen Daten neu. Der bisherige Text
+ * wird dabei ersetzt - deshalb fragt die Oberflaeche vorher nach.
+ */
+export async function entwurfNeuBerechnen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const berichtId = String(formular.get('berichtId') ?? '')
+ const bericht = await db.bericht.findFirst({
+ where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
+ })
+ if (!bericht || !bericht.zeitraumVon || !bericht.zeitraumBis) {
+ return { fehler: 'Zu diesem Bericht fehlt der Zeitraum.' }
+ }
+
+ const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts ändern.' }
+
+ const entwurf = await jahresberichtEntwurfAusDatenbank(
+ bericht.betreuteId,
+ bericht.zeitraumVon,
+ bericht.zeitraumBis,
+ )
+ await db.bericht.update({ where: { id: bericht.id }, data: sealen('Bericht', { entwurf }) })
+
+ await protokollieren({
+ aktion: 'GEAENDERT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: `${bericht.titel} (Entwurf neu berechnet)`,
+ betreuteId: bericht.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
+ return { erfolg: 'Der Entwurf wurde aus den aktuellen Daten neu aufgebaut.' }
+}
+
+/** Erzeugt PDF oder DOCX aus dem gespeicherten Entwurfstext. */
+export async function jahresberichtAusgeben(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const berichtId = String(formular.get('berichtId') ?? '')
+ const format = String(formular.get('format') ?? 'pdf') === 'docx' ? 'docx' : 'pdf'
+
+ const bericht = await db.bericht.findFirst({
+ where: { id: berichtId, art: 'JAHRESBERICHT', ...nichtGeloescht },
+ })
+ if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
+
+ const { benutzer, betreute, zugang } = await erfordereZugang(
+ bericht.betreuteId,
+ 'BERICHTE',
+ 'schreiben',
+ )
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Berichte erzeugen.' }
+
+ const entwurf = entschluesselnWennMoeglich(bericht.entwurfVerschluesselt, 'entwurfVerschluesselt')
+ if (!entwurf || entwurf.trim() === '') {
+ return { fehler: 'Der Entwurf ist leer. Schreib erst den Text, dann die Datei.' }
+ }
+
+ const erstelltAm = new Date()
+ const kopf = await kopfBauen(bericht.betreuteId, bericht.titel, {
+ zeitraumVon: bericht.zeitraumVon,
+ zeitraumBis: bericht.zeitraumBis,
+ rechtsgrundlage: '§ 1863 Abs. 3 BGB',
+ erstelltVon: benutzer.name,
+ erstelltAm,
+ })
+
+ const inhalt =
+ format === 'docx' ? await docxErzeugen(kopf, entwurf) : await jahresberichtPdf(kopf, entwurf)
+ const dateiname = berichtDateiname('jahresbericht', betreute.nachname, erstelltAm, format)
+ const mimeTyp =
+ format === 'docx'
+ ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ : 'application/pdf'
+
+ const ziel = await ablegen(bericht.betreuteId, 'berichte', dateiname, inhalt, mimeTyp)
+
+ // Die PDF-Fassung ist die abzugebende und wird am Bericht vermerkt; die
+ // DOCX ist Arbeitsfassung und bekommt einen eigenen Datensatz, damit die
+ // abgegebene Fassung nicht ueberschrieben wird.
+ if (format === 'pdf') {
+ await db.bericht.update({
+ where: { id: bericht.id },
+ data: { objektSchluessel: ziel.schluessel, dateiname, pruefsumme: ziel.pruefsumme },
+ })
+ }
+
+ await protokollieren({
+ aktion: 'ERSTELLT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: `${bericht.titel} als ${format.toUpperCase()}`,
+ betreuteId: bericht.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath(`/berichte/jahresbericht/${bericht.id}`)
+ revalidatePath('/berichte')
+ return {
+ erfolg:
+ format === 'docx'
+ ? 'Die Word-Fassung liegt bereit. Sie ist die Arbeitsfassung – abgegeben wird das PDF.'
+ : 'Das PDF liegt bereit. Prüfe es, bevor du es abgibst.',
+ }
+}
+
+/** Haelt fest, wann und wem der Bericht vorgelegt wurde. */
+export async function abgabeVermerken(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = berichtAbgabeSchema.safeParse({
+ berichtId: formular.get('berichtId'),
+ abgegebenAm: formular.get('abgegebenAm'),
+ abgegebenAn: formular.get('abgegebenAn'),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const bericht = await db.bericht.findFirst({
+ where: { id: gelesen.data.berichtId, ...nichtGeloescht },
+ select: { id: true, betreuteId: true, titel: true },
+ })
+ if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
+
+ const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts vermerken.' }
+
+ await db.bericht.update({
+ where: { id: bericht.id },
+ data: { abgegebenAm: gelesen.data.abgegebenAm, abgegebenAn: gelesen.data.abgegebenAn },
+ })
+
+ await protokollieren({
+ aktion: 'GEAENDERT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: `${bericht.titel} abgegeben an ${gelesen.data.abgegebenAn}`,
+ betreuteId: bericht.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/berichte')
+ return { erfolg: 'Abgabe vermerkt.' }
+}
+
+// -----------------------------------------------------------------------------
+// Belegmappe
+// -----------------------------------------------------------------------------
+
+/**
+ * Belegmappe fuer einen Zeitraum oder einen Vorgang. Das Zusammenstellen
+ * selbst liegt beim Dokumentenmodul - dort sitzt das Wissen darueber, wie
+ * ein Beleg zu einer Buchung gehoert.
+ */
+export async function belegmappeAnlegen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = belegmappeSchema.safeParse({
+ betreuteId: formular.get('betreuteId'),
+ quelle: formular.get('quelle'),
+ zeitraumVon: formular.get('zeitraumVon') ?? '',
+ zeitraumBis: formular.get('zeitraumBis') ?? '',
+ vorgangId: formular.get('vorgangId') ?? '',
+ titel: formular.get('titel') ?? '',
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const { betreuteId, quelle, zeitraumVon, zeitraumBis, vorgangId, titel } = gelesen.data
+ const { benutzer, betreute, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Belegmappe erzeugen.' }
+
+ // Die Auswahl wird in die Form gebracht, die das Dokumentenmodul erwartet.
+ const auswahl: Belegmappenauswahl =
+ quelle === 'zeitraum'
+ ? { art: 'zeitraum', von: zeitraumVon!, bis: zeitraumBis! }
+ : { art: 'vorgang', vorgangId: vorgangId! }
+
+ let ergebnis: Mappenergebnis
+ try {
+ ergebnis = await belegmappeErzeugen(betreuteId, auswahl, benutzer.name)
+ } catch (fehler) {
+ return {
+ fehler:
+ 'Die Belegmappe konnte nicht erzeugt werden: ' +
+ `${(fehler as Error).message}. Prüfe, ob zu dieser Auswahl überhaupt Belege hinterlegt sind.`,
+ }
+ }
+
+ if (ergebnis.anzahl === 0) {
+ return { fehler: 'Zu dieser Auswahl gibt es keine Belege. Wähle einen anderen Zeitraum oder Vorgang.' }
+ }
+
+ const ziel = await ablegen(betreuteId, 'berichte', ergebnis.dateiname, ergebnis.pdf, 'application/pdf')
+
+ const bericht = await db.bericht.create({
+ data: {
+ betreuteId,
+ art: 'BELEGMAPPE',
+ titel:
+ titel && titel !== ''
+ ? titel
+ : quelle === 'zeitraum'
+ ? `Belegmappe ${zeitraumVon?.getUTCFullYear() ?? ''}`
+ : 'Belegmappe zum Vorgang',
+ zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
+ zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
+ objektSchluessel: ziel.schluessel,
+ dateiname: ergebnis.dateiname,
+ pruefsumme: ziel.pruefsumme,
+ erstelltVonId: benutzer.id,
+ },
+ })
+
+ await protokollieren({
+ aktion: 'ERSTELLT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: `${bericht.titel} (${ergebnis.anzahl} Belege, ${betreute.nachname})`,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/berichte')
+
+ const hinweise =
+ ergebnis.hinweise.length > 0 ? ` Nicht aufgenommen: ${ergebnis.hinweise.join(' ')}` : ''
+
+ return {
+ berichtId: bericht.id,
+ erfolg: `Belegmappe mit ${ergebnis.anzahl} Belegen erzeugt.${hinweise}`,
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Unterlagenpaket
+// -----------------------------------------------------------------------------
+
+export async function unterlagenpaketAnlegen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = unterlagenpaketSchema.safeParse({
+ betreuteId: formular.get('betreuteId'),
+ antragId: formular.get('antragId'),
+ nachforderungIds: formular.getAll('nachforderungIds').map(String),
+ anschreibenZusatz: formular.get('anschreibenZusatz') ?? '',
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const { betreuteId, antragId, nachforderungIds, anschreibenZusatz } = gelesen.data
+ const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du kein Paket erzeugen.' }
+ // Die Unterlagen stammen aus der Sozialhilfeakte - auch dort muss gelesen
+ // werden duerfen.
+ await erfordereZugang(betreuteId, 'SOZIALHILFE', 'lesen')
+
+ const erstelltAm = new Date()
+ const ergebnis = await unterlagenpaketErzeugen({
+ betreuteId,
+ antragId,
+ nachforderungIds,
+ anschreibenZusatz,
+ erstelltVon: benutzer.name,
+ erstelltAm,
+ })
+
+ const ziel = await ablegen(
+ betreuteId,
+ 'berichte',
+ ergebnis.dateiname,
+ ergebnis.pdf,
+ 'application/pdf',
+ )
+
+ const bericht = await db.bericht.create({
+ data: {
+ betreuteId,
+ art: 'UNTERLAGENPAKET_BEZIRK',
+ titel: `Unterlagen an den Träger (${ergebnis.aufstellung.length} Positionen)`,
+ stichtag: erstelltAm,
+ objektSchluessel: ziel.schluessel,
+ dateiname: ergebnis.dateiname,
+ pruefsumme: ziel.pruefsumme,
+ erstelltVonId: benutzer.id,
+ },
+ })
+
+ // Positionen, die tatsaechlich mitgehen, gelten als uebersandt.
+ const uebersandt = ergebnis.aufstellung.filter((a) => a.beigefuegt).map((a) => a.unterlage.id)
+ if (uebersandt.length > 0) {
+ await db.nachforderung.updateMany({
+ where: { id: { in: uebersandt }, antragId },
+ data: { status: 'UEBERSANDT', uebersandtAm: erstelltAm },
+ })
+ }
+
+ await protokollieren({
+ aktion: 'ERSTELLT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: bericht.titel,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/berichte')
+ revalidatePath('/sozialhilfe')
+
+ const uebersprungen =
+ ergebnis.uebersprungen.length > 0
+ ? ` Nicht lesbar und deshalb nicht enthalten: ${ergebnis.uebersprungen
+ .map((u) => u.bezeichnung)
+ .join(', ')}.`
+ : ''
+
+ return {
+ berichtId: bericht.id,
+ erfolg: `Das Paket ist fertig: ${uebersandt.length} beigefügte Unterlagen.${uebersprungen}`,
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Berichte loeschen (Papierkorb)
+// -----------------------------------------------------------------------------
+
+export async function berichtLoeschen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const berichtId = String(formular.get('berichtId') ?? '')
+ const bericht = await db.bericht.findFirst({
+ where: { id: berichtId, ...nichtGeloescht },
+ select: { id: true, betreuteId: true, titel: true, art: true },
+ })
+ if (!bericht) return { fehler: 'Diesen Bericht gibt es nicht (mehr).' }
+
+ const { benutzer, zugang } = await erfordereZugang(bericht.betreuteId, 'BERICHTE', 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
+
+ await db.bericht.update({ where: { id: bericht.id }, data: { geloeschtAm: new Date() } })
+
+ await protokollieren({
+ aktion: 'GELOESCHT',
+ entitaet: 'Bericht',
+ entitaetId: bericht.id,
+ bezeichnung: bericht.titel,
+ betreuteId: bericht.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/berichte')
+ return { erfolg: 'In den Papierkorb verschoben. Dort liegt er 30 Tage.' }
+}
+
+// -----------------------------------------------------------------------------
+// Vollexport
+// -----------------------------------------------------------------------------
+
+/**
+ * Der Export selbst laeuft ueber die Route unter /berichte/export/datei,
+ * weil ein ZIP als Strom ausgeliefert wird und eine Server Action keinen
+ * Strom zurueckgeben kann. Diese Aktion erzwingt vorher die frische
+ * Anmeldung; Protokolleintrag und Bericht-Datensatz schreibt die Route,
+ * sobald der Strom wirklich beginnt.
+ */
+export async function exportVorbereiten(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const betreuteId = String(formular.get('betreuteId') ?? '')
+ const { benutzer, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
+
+ if (zugang.rolle !== 'BETREUER' && zugang.rolle !== 'ADMIN') {
+ return { fehler: 'Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.' }
+ }
+
+ await erfordereFrischeAnmeldung(benutzer, '/berichte/export')
+ return { erfolg: 'Bestätigt. Der Export kann jetzt geladen werden.' }
+}
diff --git a/src/server/actions/freigaben.ts b/src/server/actions/freigaben.ts
index d55bb7c..420eb90 100644
--- a/src/server/actions/freigaben.ts
+++ b/src/server/actions/freigaben.ts
@@ -1,385 +1,385 @@
-'use server'
-
-import { revalidatePath } from 'next/cache'
-import type { Modul } from '@prisma/client'
-import { db, nichtGeloescht } from '@/lib/db'
-import { protokollieren } from '@/lib/audit'
-import { passwortHashen } from '@/lib/crypto/hash'
-import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
-import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
-import {
- benutzerAktivSchema,
- benutzerAnlegenSchema,
- darfBenutzerVerwalten,
- darfFreigabenVergeben,
- freigabeAenderung,
- freigabenNormalisieren,
- freigabenSchema,
- letzterAdminGeschuetzt,
- rolleAendernSchema,
- sitzungenBeendenSchema,
-} from '@/lib/validierung/freigaben'
-
-/**
- * Benutzerverwaltung und Modulfreigaben.
- *
- * Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
- * Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
- * ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
- * frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
- * betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
- * wem wann was freigegeben hat.
- */
-
-export type Aktionszustand = {
- fehler?: string
- felder?: Record
- erfolg?: string
- warnung?: string
-}
-
-function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record {
- const felder: Record = {}
- for (const problem of fehler) {
- const schluessel = String(problem.path[0] ?? 'formular')
- if (!felder[schluessel]) felder[schluessel] = problem.message
- }
- return felder
-}
-
-async function administratorSein(weiter: string) {
- const benutzer = await erfordereBenutzer(weiter)
- if (!darfBenutzerVerwalten(benutzer.rolle)) {
- throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
- }
- await erfordereFrischeAnmeldung(benutzer, weiter)
- return benutzer
-}
-
-async function anzahlAktiverAdmins(): Promise {
- return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
-}
-
-// -----------------------------------------------------------------------------
-// Benutzer
-// -----------------------------------------------------------------------------
-
-export async function benutzerAnlegen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = benutzerAnlegenSchema.safeParse({
- name: formular.get('name'),
- email: formular.get('email'),
- rolle: formular.get('rolle'),
- passwort: formular.get('passwort'),
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const admin = await administratorSein('/einstellungen/benutzer')
-
- const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
- if (vorhanden) {
- return {
- fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
- felder: { email: 'Bereits vergeben.' },
- }
- }
-
- const neu = await db.user.create({
- data: {
- name: gelesen.data.name,
- email: gelesen.data.email,
- rolle: gelesen.data.rolle,
- passwortHash: await passwortHashen(gelesen.data.passwort),
- },
- })
-
- await protokollieren({
- aktion: 'ERSTELLT',
- entitaet: 'User',
- entitaetId: neu.id,
- bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
- userId: admin.id,
- userEmail: admin.email,
- userName: admin.name,
- })
-
- revalidatePath('/einstellungen/benutzer')
- return {
- erfolg:
- `${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
- `werden – ohne ihn kommt niemand an Fachdaten.`,
- }
-}
-
-export async function rolleAendern(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = rolleAendernSchema.safeParse({
- userId: formular.get('userId'),
- rolle: formular.get('rolle'),
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const admin = await administratorSein('/einstellungen/benutzer')
-
- const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
- if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
- if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
-
- const schutz = letzterAdminGeschuetzt({
- zielRolleVorher: ziel.rolle,
- zielRolleNachher: gelesen.data.rolle,
- zielBleibtAktiv: ziel.aktiv,
- anzahlAktiverAdmins: await anzahlAktiverAdmins(),
- })
- if (schutz) return { fehler: schutz }
-
- // Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
- // sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
- await db.user.update({
- where: { id: ziel.id },
- data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
- })
-
- await protokollieren({
- aktion: 'ROLLE_GEAENDERT',
- entitaet: 'User',
- entitaetId: ziel.id,
- bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
- userId: admin.id,
- userEmail: admin.email,
- userName: admin.name,
- aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
- })
-
- revalidatePath('/einstellungen/benutzer')
- return {
- erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
- }
-}
-
-export async function benutzerAktivSetzen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = benutzerAktivSchema.safeParse({
- userId: formular.get('userId'),
- aktiv: formular.get('aktiv') === 'ja',
- })
- if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
-
- const admin = await administratorSein('/einstellungen/benutzer')
-
- const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
- if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
- if (ziel.id === admin.id && !gelesen.data.aktiv) {
- return { fehler: 'Du kannst dich nicht selbst abschalten.' }
- }
-
- const schutz = letzterAdminGeschuetzt({
- zielRolleVorher: ziel.rolle,
- zielRolleNachher: ziel.rolle,
- zielBleibtAktiv: gelesen.data.aktiv,
- anzahlAktiverAdmins: await anzahlAktiverAdmins(),
- })
- if (schutz) return { fehler: schutz }
-
- await db.user.update({
- where: { id: ziel.id },
- data: {
- aktiv: gelesen.data.aktiv,
- // Wer abgeschaltet wird, ist sofort abgemeldet.
- sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
- },
- })
-
- await protokollieren({
- aktion: 'GEAENDERT',
- entitaet: 'User',
- entitaetId: ziel.id,
- bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
- userId: admin.id,
- userEmail: admin.email,
- userName: admin.name,
- aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
- })
-
- revalidatePath('/einstellungen/benutzer')
- return {
- erfolg: gelesen.data.aktiv
- ? `${ziel.name} kann sich wieder anmelden.`
- : `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
- }
-}
-
-export async function sitzungenBeenden(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
- if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
-
- const admin = await administratorSein('/einstellungen/benutzer')
-
- const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
- if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
-
- await db.user.update({
- where: { id: ziel.id },
- data: { sitzungsGeneration: { increment: 1 } },
- })
-
- await protokollieren({
- aktion: 'GEAENDERT',
- entitaet: 'User',
- entitaetId: ziel.id,
- bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
- userId: admin.id,
- userEmail: admin.email,
- userName: admin.name,
- aenderung: {
- sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
- },
- })
-
- revalidatePath('/einstellungen/benutzer')
- return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
-}
-
-// -----------------------------------------------------------------------------
-// Freigaben je betreuter Person
-// -----------------------------------------------------------------------------
-
-export async function freigabenSpeichern(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = freigabenSchema.safeParse({
- userId: formular.get('userId'),
- betreuteId: formular.get('betreuteId'),
- rolle: formular.get('rolle'),
- freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
- })
- if (!gelesen.success) {
- return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
- }
-
- const { userId, betreuteId, rolle } = gelesen.data
- const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
-
- if (!darfFreigabenVergeben(zugang)) {
- return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
- }
- await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
-
- const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
- if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
-
- const module = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
-
- const vorher = await db.betreuungsZugang.findUnique({
- where: { userId_betreuteId: { userId, betreuteId } },
- select: { rolle: true, freigegebeneModule: true },
- })
-
- const aenderung = freigabeAenderung(
- vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
- { rolle, freigegebeneModule: module },
- )
-
- await db.betreuungsZugang.upsert({
- where: { userId_betreuteId: { userId, betreuteId } },
- create: { userId, betreuteId, rolle, freigegebeneModule: module },
- update: { rolle, freigegebeneModule: module },
- })
-
- // Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
- // weiterbenutzen koennen.
- if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
- await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
- }
-
- const beschreibung = [
- aenderung.rolleGeaendert
- ? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
- : null,
- aenderung.hinzugefuegt.length > 0
- ? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
- : null,
- aenderung.entfernt.length > 0
- ? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
- : null,
- ]
- .filter(Boolean)
- .join('; ')
-
- await protokollieren({
- aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
- entitaet: 'BetreuungsZugang',
- entitaetId: userId,
- bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
- betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- aenderung: {
- rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
- freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: module },
- },
- })
-
- revalidatePath('/einstellungen/freigaben')
- revalidatePath('/', 'layout')
-
- const warnung =
- aenderung.sensibelHinzugefuegt.length > 0
- ? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
- `enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
- : undefined
-
- return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
-}
-
-export async function zugangEntziehen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const userId = String(formular.get('userId') ?? '')
- const betreuteId = String(formular.get('betreuteId') ?? '')
-
- const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
- if (!darfFreigabenVergeben(zugang)) {
- return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
- }
- await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
-
- if (userId === benutzer.id) {
- return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
- }
-
- const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
- await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
- await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
-
- await protokollieren({
- aktion: 'FREIGABE_GEAENDERT',
- entitaet: 'BetreuungsZugang',
- entitaetId: userId,
- bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
- betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/einstellungen/freigaben')
- revalidatePath('/', 'layout')
- return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
-}
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import type { Modul } from '@prisma/client'
+import { db, nichtGeloescht } from '@/lib/db'
+import { protokollieren } from '@/lib/audit'
+import { passwortHashen } from '@/lib/crypto/hash'
+import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
+import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
+import {
+ benutzerAktivSchema,
+ benutzerAnlegenSchema,
+ darfBenutzerVerwalten,
+ darfFreigabenVergeben,
+ freigabeAenderung,
+ freigabenNormalisieren,
+ freigabenSchema,
+ letzterAdminGeschuetzt,
+ rolleAendernSchema,
+ sitzungenBeendenSchema,
+} from '@/lib/validierung/freigaben'
+
+/**
+ * Benutzerverwaltung und Modulfreigaben.
+ *
+ * Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
+ * Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
+ * ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
+ * frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
+ * betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
+ * wem wann was freigegeben hat.
+ */
+
+export type Aktionszustand = {
+ fehler?: string
+ felder?: Record
+ erfolg?: string
+ warnung?: string
+}
+
+function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record {
+ const felder: Record = {}
+ for (const problem of fehler) {
+ const schluessel = String(problem.path[0] ?? 'formular')
+ if (!felder[schluessel]) felder[schluessel] = problem.message
+ }
+ return felder
+}
+
+async function administratorSein(weiter: string) {
+ const benutzer = await erfordereBenutzer(weiter)
+ if (!darfBenutzerVerwalten(benutzer.rolle)) {
+ throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
+ }
+ await erfordereFrischeAnmeldung(benutzer, weiter)
+ return benutzer
+}
+
+async function anzahlAktiverAdmins(): Promise {
+ return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
+}
+
+// -----------------------------------------------------------------------------
+// Benutzer
+// -----------------------------------------------------------------------------
+
+export async function benutzerAnlegen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = benutzerAnlegenSchema.safeParse({
+ name: formular.get('name'),
+ email: formular.get('email'),
+ rolle: formular.get('rolle'),
+ passwort: formular.get('passwort'),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const admin = await administratorSein('/einstellungen/benutzer')
+
+ const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
+ if (vorhanden) {
+ return {
+ fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
+ felder: { email: 'Bereits vergeben.' },
+ }
+ }
+
+ const neu = await db.user.create({
+ data: {
+ name: gelesen.data.name,
+ email: gelesen.data.email,
+ rolle: gelesen.data.rolle,
+ passwortHash: await passwortHashen(gelesen.data.passwort),
+ },
+ })
+
+ await protokollieren({
+ aktion: 'ERSTELLT',
+ entitaet: 'User',
+ entitaetId: neu.id,
+ bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
+ userId: admin.id,
+ userEmail: admin.email,
+ userName: admin.name,
+ })
+
+ revalidatePath('/einstellungen/benutzer')
+ return {
+ erfolg:
+ `${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
+ `werden – ohne ihn kommt niemand an Fachdaten.`,
+ }
+}
+
+export async function rolleAendern(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = rolleAendernSchema.safeParse({
+ userId: formular.get('userId'),
+ rolle: formular.get('rolle'),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const admin = await administratorSein('/einstellungen/benutzer')
+
+ const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
+ if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
+ if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
+
+ const schutz = letzterAdminGeschuetzt({
+ zielRolleVorher: ziel.rolle,
+ zielRolleNachher: gelesen.data.rolle,
+ zielBleibtAktiv: ziel.aktiv,
+ anzahlAktiverAdmins: await anzahlAktiverAdmins(),
+ })
+ if (schutz) return { fehler: schutz }
+
+ // Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
+ // sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
+ await db.user.update({
+ where: { id: ziel.id },
+ data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
+ })
+
+ await protokollieren({
+ aktion: 'ROLLE_GEAENDERT',
+ entitaet: 'User',
+ entitaetId: ziel.id,
+ bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
+ userId: admin.id,
+ userEmail: admin.email,
+ userName: admin.name,
+ aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
+ })
+
+ revalidatePath('/einstellungen/benutzer')
+ return {
+ erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
+ }
+}
+
+export async function benutzerAktivSetzen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = benutzerAktivSchema.safeParse({
+ userId: formular.get('userId'),
+ aktiv: formular.get('aktiv') === 'ja',
+ })
+ if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
+
+ const admin = await administratorSein('/einstellungen/benutzer')
+
+ const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
+ if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
+ if (ziel.id === admin.id && !gelesen.data.aktiv) {
+ return { fehler: 'Du kannst dich nicht selbst abschalten.' }
+ }
+
+ const schutz = letzterAdminGeschuetzt({
+ zielRolleVorher: ziel.rolle,
+ zielRolleNachher: ziel.rolle,
+ zielBleibtAktiv: gelesen.data.aktiv,
+ anzahlAktiverAdmins: await anzahlAktiverAdmins(),
+ })
+ if (schutz) return { fehler: schutz }
+
+ await db.user.update({
+ where: { id: ziel.id },
+ data: {
+ aktiv: gelesen.data.aktiv,
+ // Wer abgeschaltet wird, ist sofort abgemeldet.
+ sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
+ },
+ })
+
+ await protokollieren({
+ aktion: 'GEAENDERT',
+ entitaet: 'User',
+ entitaetId: ziel.id,
+ bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
+ userId: admin.id,
+ userEmail: admin.email,
+ userName: admin.name,
+ aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
+ })
+
+ revalidatePath('/einstellungen/benutzer')
+ return {
+ erfolg: gelesen.data.aktiv
+ ? `${ziel.name} kann sich wieder anmelden.`
+ : `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
+ }
+}
+
+export async function sitzungenBeenden(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
+ if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
+
+ const admin = await administratorSein('/einstellungen/benutzer')
+
+ const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
+ if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
+
+ await db.user.update({
+ where: { id: ziel.id },
+ data: { sitzungsGeneration: { increment: 1 } },
+ })
+
+ await protokollieren({
+ aktion: 'GEAENDERT',
+ entitaet: 'User',
+ entitaetId: ziel.id,
+ bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
+ userId: admin.id,
+ userEmail: admin.email,
+ userName: admin.name,
+ aenderung: {
+ sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
+ },
+ })
+
+ revalidatePath('/einstellungen/benutzer')
+ return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
+}
+
+// -----------------------------------------------------------------------------
+// Freigaben je betreuter Person
+// -----------------------------------------------------------------------------
+
+export async function freigabenSpeichern(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = freigabenSchema.safeParse({
+ userId: formular.get('userId'),
+ betreuteId: formular.get('betreuteId'),
+ rolle: formular.get('rolle'),
+ freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
+ })
+ if (!gelesen.success) {
+ return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
+ }
+
+ const { userId, betreuteId, rolle } = gelesen.data
+ const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
+
+ if (!darfFreigabenVergeben(zugang)) {
+ return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
+ }
+ await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
+
+ const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
+ if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
+
+ const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
+
+ const vorher = await db.betreuungsZugang.findUnique({
+ where: { userId_betreuteId: { userId, betreuteId } },
+ select: { rolle: true, freigegebeneModule: true },
+ })
+
+ const aenderung = freigabeAenderung(
+ vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
+ { rolle, freigegebeneModule: freigaben },
+ )
+
+ await db.betreuungsZugang.upsert({
+ where: { userId_betreuteId: { userId, betreuteId } },
+ create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
+ update: { rolle, freigegebeneModule: freigaben },
+ })
+
+ // Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
+ // weiterbenutzen koennen.
+ if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
+ await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
+ }
+
+ const beschreibung = [
+ aenderung.rolleGeaendert
+ ? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
+ : null,
+ aenderung.hinzugefuegt.length > 0
+ ? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
+ : null,
+ aenderung.entfernt.length > 0
+ ? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join('; ')
+
+ await protokollieren({
+ aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
+ entitaet: 'BetreuungsZugang',
+ entitaetId: userId,
+ bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ aenderung: {
+ rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
+ freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
+ },
+ })
+
+ revalidatePath('/einstellungen/freigaben')
+ revalidatePath('/', 'layout')
+
+ const warnung =
+ aenderung.sensibelHinzugefuegt.length > 0
+ ? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
+ `enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
+ : undefined
+
+ return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
+}
+
+export async function zugangEntziehen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const userId = String(formular.get('userId') ?? '')
+ const betreuteId = String(formular.get('betreuteId') ?? '')
+
+ const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
+ if (!darfFreigabenVergeben(zugang)) {
+ return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
+ }
+ await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
+
+ if (userId === benutzer.id) {
+ return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
+ }
+
+ const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
+ await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
+ await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
+
+ await protokollieren({
+ aktion: 'FREIGABE_GEAENDERT',
+ entitaet: 'BetreuungsZugang',
+ entitaetId: userId,
+ bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
+ betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/einstellungen/freigaben')
+ revalidatePath('/', 'layout')
+ return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
+}
diff --git a/src/server/actions/papierkorb.ts b/src/server/actions/papierkorb.ts
index f9e7c61..6fca82e 100644
--- a/src/server/actions/papierkorb.ts
+++ b/src/server/actions/papierkorb.ts
@@ -1,257 +1,285 @@
-'use server'
-
-import { revalidatePath } from 'next/cache'
-import type { Modul } from '@prisma/client'
-import { db, papierkorbGrenze } from '@/lib/db'
-import { protokollieren } from '@/lib/audit'
-import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
-import { entfernen } from '@/lib/speicher'
-import { papierkorbSchema } from '@/lib/validierung/freigaben'
-
-/**
- * Papierkorb.
- *
- * Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
- * bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
- * einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
- * echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
- *
- * Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
- * Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
- * es ihn gab und wer ihn entfernt hat.
- */
-
-export type Aktionszustand = {
- fehler?: string
- erfolg?: string
-}
-
-/**
- * Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
- * dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
- * daraus darf niemals ein beliebiger Datenbankzugriff werden.
- */
-type Eintragsart = {
- bezeichnung: string
- mehrzahl: string
- modul: Modul
- laden: (betreuteId: string, seit: Date) => Promise
- wiederherstellen: (id: string, betreuteId: string) => Promise
- endgueltigLoeschen: (id: string, betreuteId: string) => Promise
-}
-
-export type PapierkorbEintrag = {
- id: string
- entitaet: string
- bezeichnung: string
- geloeschtAm: Date
-}
-
-/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
-function einfach(
- entitaet: string,
- bezeichnung: string,
- mehrzahl: string,
- modul: Modul,
- zugriff: {
- findMany: (args: unknown) => Promise
- update: (args: unknown) => Promise
- delete: (args: unknown) => Promise
- },
- titelFeld: string,
-): Eintragsart {
- return {
- bezeichnung,
- mehrzahl,
- modul,
- laden: async (betreuteId, seit) => {
- const treffer = await zugriff.findMany({
- where: { betreuteId, geloeschtAm: { gte: seit } },
- orderBy: { geloeschtAm: 'desc' },
- })
- return treffer.map((datensatz) => ({
- id: datensatz.id,
- entitaet,
- bezeichnung:
- String((datensatz as unknown as Record)[titelFeld] ?? '(ohne Titel)'),
- geloeschtAm: datensatz.geloeschtAm!,
- }))
- },
- wiederherstellen: async (id, betreuteId) => {
- await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
- },
- endgueltigLoeschen: async (id, betreuteId) => {
- await zugriff.delete({ where: { id, betreuteId } })
- },
- }
-}
-
-function eintragsarten(): Record {
- /* eslint-disable @typescript-eslint/no-explicit-any */
- const z = db as any
- return {
- Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
- Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
- Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
- Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
- Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
- Vermoegensposten: einfach(
- 'Vermoegensposten',
- 'Vermögensposten',
- 'Vermögensposten',
- 'FINANZEN',
- z.vermoegensposten,
- 'bezeichnung',
- ),
- Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
- Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
- Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
- Kontaktprotokoll: einfach(
- 'Kontaktprotokoll',
- 'Kontaktprotokoll',
- 'Kontaktprotokolle',
- 'KONTAKTE',
- z.kontaktprotokoll,
- 'gespraechspartner',
- ),
- Gesundheitsereignis: einfach(
- 'Gesundheitsereignis',
- 'Gesundheitsereignis',
- 'Gesundheitsereignisse',
- 'GESUNDHEIT',
- z.gesundheitsereignis,
- 'titel',
- ),
- Genehmigung: einfach(
- 'Genehmigung',
- 'Genehmigung',
- 'Genehmigungen',
- 'VORGAENGE',
- z.genehmigung,
- 'titel',
- ),
- SozialhilfeAntrag: einfach(
- 'SozialhilfeAntrag',
- 'Sozialhilfeantrag',
- 'Sozialhilfeanträge',
- 'SOZIALHILFE',
- z.sozialhilfeAntrag,
- 'leistungsart',
- ),
- Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
- Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
- }
- /* eslint-enable @typescript-eslint/no-explicit-any */
-}
-
-/** Alles, was in den letzten 30 Tagen geloescht wurde. */
-export async function papierkorbLaden(betreuteId: string): Promise<
- { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
-> {
- const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
- const seit = papierkorbGrenze()
-
- const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
- for (const [art, beschreibung] of Object.entries(eintragsarten())) {
- // Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
- if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
- continue
- }
- const eintraege = await beschreibung.laden(betreuteId, seit)
- if (eintraege.length > 0) {
- gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
- }
- }
- return gruppen
-}
-
-export async function wiederherstellen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = papierkorbSchema.safeParse({
- betreuteId: formular.get('betreuteId'),
- entitaet: formular.get('entitaet'),
- entitaetId: formular.get('entitaetId'),
- })
- if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
-
- const art = eintragsarten()[gelesen.data.entitaet]
- if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
-
- const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
-
- await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
-
- await protokollieren({
- aktion: 'WIEDERHERGESTELLT',
- entitaet: gelesen.data.entitaet,
- entitaetId: gelesen.data.entitaetId,
- bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
- betreuteId: gelesen.data.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/einstellungen/papierkorb')
- return { erfolg: `${art.bezeichnung} ist wieder da.` }
-}
-
-/**
- * Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
- * Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
- */
-export async function endgueltigLoeschen(
- _zustand: Aktionszustand,
- formular: FormData,
-): Promise {
- const gelesen = papierkorbSchema.safeParse({
- betreuteId: formular.get('betreuteId'),
- entitaet: formular.get('entitaet'),
- entitaetId: formular.get('entitaetId'),
- })
- if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
-
- const art = eintragsarten()[gelesen.data.entitaet]
- if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
-
- const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
- if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
- await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
-
- // Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
- // entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
- // die ausdruecklich verschwinden sollten.
- if (gelesen.data.entitaet === 'Dokument' || gelesen.data.entitaet === 'Bericht') {
- const tabelle = gelesen.data.entitaet === 'Dokument' ? db.dokument : db.bericht
- const datensatz = await tabelle.findFirst({
- where: { id: gelesen.data.entitaetId, betreuteId: gelesen.data.betreuteId },
- select: { objektSchluessel: true },
- })
- if (datensatz?.objektSchluessel) {
- try {
- await entfernen(datensatz.objektSchluessel)
- } catch (fehler) {
- console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
- }
- }
- }
-
- await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
-
- await protokollieren({
- aktion: 'ENDGUELTIG_GELOESCHT',
- entitaet: gelesen.data.entitaet,
- entitaetId: gelesen.data.entitaetId,
- bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
- betreuteId: gelesen.data.betreuteId,
- userId: benutzer.id,
- userEmail: benutzer.email,
- userName: benutzer.name,
- })
-
- revalidatePath('/einstellungen/papierkorb')
- return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
-}
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import type { Modul } from '@prisma/client'
+import { db, papierkorbGrenze } from '@/lib/db'
+import { protokollieren } from '@/lib/audit'
+import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
+import { entfernen } from '@/lib/speicher'
+import { papierkorbSchema } from '@/lib/validierung/freigaben'
+
+/**
+ * Papierkorb.
+ *
+ * Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
+ * bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
+ * einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
+ * echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
+ *
+ * Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
+ * Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
+ * es ihn gab und wer ihn entfernt hat.
+ */
+
+export type Aktionszustand = {
+ fehler?: string
+ erfolg?: string
+}
+
+/**
+ * Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
+ * dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
+ * daraus darf niemals ein beliebiger Datenbankzugriff werden.
+ */
+type Eintragsart = {
+ bezeichnung: string
+ mehrzahl: string
+ modul: Modul
+ laden: (betreuteId: string, seit: Date) => Promise
+ wiederherstellen: (id: string, betreuteId: string) => Promise
+ endgueltigLoeschen: (id: string, betreuteId: string) => Promise
+}
+
+export type PapierkorbEintrag = {
+ id: string
+ entitaet: string
+ bezeichnung: string
+ geloeschtAm: Date
+}
+
+/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
+function einfach(
+ entitaet: string,
+ bezeichnung: string,
+ mehrzahl: string,
+ modul: Modul,
+ zugriff: {
+ findMany: (args: unknown) => Promise
+ update: (args: unknown) => Promise
+ delete: (args: unknown) => Promise
+ },
+ titelFeld: string,
+): Eintragsart {
+ return {
+ bezeichnung,
+ mehrzahl,
+ modul,
+ laden: async (betreuteId, seit) => {
+ const treffer = await zugriff.findMany({
+ where: { betreuteId, geloeschtAm: { gte: seit } },
+ orderBy: { geloeschtAm: 'desc' },
+ })
+ return treffer.map((datensatz) => ({
+ id: datensatz.id,
+ entitaet,
+ bezeichnung:
+ String((datensatz as unknown as Record)[titelFeld] ?? '(ohne Titel)'),
+ geloeschtAm: datensatz.geloeschtAm!,
+ }))
+ },
+ wiederherstellen: async (id, betreuteId) => {
+ await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
+ },
+ endgueltigLoeschen: async (id, betreuteId) => {
+ await zugriff.delete({ where: { id, betreuteId } })
+ },
+ }
+}
+
+function eintragsarten(): Record {
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const z = db as any
+ return {
+ Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
+ Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
+ Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
+ Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
+ Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
+ Vermoegensposten: einfach(
+ 'Vermoegensposten',
+ 'Vermögensposten',
+ 'Vermögensposten',
+ 'FINANZEN',
+ z.vermoegensposten,
+ 'bezeichnung',
+ ),
+ Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
+ Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
+ Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
+ Kontaktprotokoll: einfach(
+ 'Kontaktprotokoll',
+ 'Kontaktprotokoll',
+ 'Kontaktprotokolle',
+ 'KONTAKTE',
+ z.kontaktprotokoll,
+ 'gespraechspartner',
+ ),
+ Gesundheitsereignis: einfach(
+ 'Gesundheitsereignis',
+ 'Gesundheitsereignis',
+ 'Gesundheitsereignisse',
+ 'GESUNDHEIT',
+ z.gesundheitsereignis,
+ 'titel',
+ ),
+ Genehmigung: einfach(
+ 'Genehmigung',
+ 'Genehmigung',
+ 'Genehmigungen',
+ 'VORGAENGE',
+ z.genehmigung,
+ 'titel',
+ ),
+ SozialhilfeAntrag: einfach(
+ 'SozialhilfeAntrag',
+ 'Sozialhilfeantrag',
+ 'Sozialhilfeanträge',
+ 'SOZIALHILFE',
+ z.sozialhilfeAntrag,
+ 'leistungsart',
+ ),
+ Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
+ Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
+ }
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+}
+
+/** Alles, was in den letzten 30 Tagen geloescht wurde. */
+export async function papierkorbLaden(betreuteId: string): Promise<
+ { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
+> {
+ const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
+ const seit = papierkorbGrenze()
+
+ const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
+ for (const [art, beschreibung] of Object.entries(eintragsarten())) {
+ // Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
+ if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
+ continue
+ }
+ const eintraege = await beschreibung.laden(betreuteId, seit)
+ if (eintraege.length > 0) {
+ gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
+ }
+ }
+ return gruppen
+}
+
+/**
+ * Haengt an diesem Datensatz eine Datei im Objektspeicher?
+ *
+ * Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
+ * eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
+ * eigene Signaturen, und eine Variable, die mal das eine und mal das
+ * andere Delegate haelt, laesst sich nicht mehr aufrufen.
+ */
+async function objektSchluesselFinden(
+ entitaet: string,
+ entitaetId: string,
+ betreuteId: string,
+): Promise {
+ if (entitaet === 'Dokument') {
+ const dokument = await db.dokument.findFirst({
+ where: { id: entitaetId, betreuteId },
+ select: { objektSchluessel: true },
+ })
+ return dokument?.objektSchluessel ?? null
+ }
+ if (entitaet === 'Bericht') {
+ const bericht = await db.bericht.findFirst({
+ where: { id: entitaetId, betreuteId },
+ select: { objektSchluessel: true },
+ })
+ return bericht?.objektSchluessel ?? null
+ }
+ return null
+}
+
+export async function wiederherstellen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = papierkorbSchema.safeParse({
+ betreuteId: formular.get('betreuteId'),
+ entitaet: formular.get('entitaet'),
+ entitaetId: formular.get('entitaetId'),
+ })
+ if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
+
+ const art = eintragsarten()[gelesen.data.entitaet]
+ if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
+
+ const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
+
+ await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
+
+ await protokollieren({
+ aktion: 'WIEDERHERGESTELLT',
+ entitaet: gelesen.data.entitaet,
+ entitaetId: gelesen.data.entitaetId,
+ bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
+ betreuteId: gelesen.data.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/einstellungen/papierkorb')
+ return { erfolg: `${art.bezeichnung} ist wieder da.` }
+}
+
+/**
+ * Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
+ * Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
+ */
+export async function endgueltigLoeschen(
+ _zustand: Aktionszustand,
+ formular: FormData,
+): Promise {
+ const gelesen = papierkorbSchema.safeParse({
+ betreuteId: formular.get('betreuteId'),
+ entitaet: formular.get('entitaet'),
+ entitaetId: formular.get('entitaetId'),
+ })
+ if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
+
+ const art = eintragsarten()[gelesen.data.entitaet]
+ if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
+
+ const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
+ if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
+ await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
+
+ // Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
+ // entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
+ // die ausdruecklich verschwinden sollten.
+ const schluessel = await objektSchluesselFinden(
+ gelesen.data.entitaet,
+ gelesen.data.entitaetId,
+ gelesen.data.betreuteId,
+ )
+ if (schluessel) {
+ try {
+ await entfernen(schluessel)
+ } catch (fehler) {
+ console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
+ }
+ }
+
+ await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
+
+ await protokollieren({
+ aktion: 'ENDGUELTIG_GELOESCHT',
+ entitaet: gelesen.data.entitaet,
+ entitaetId: gelesen.data.entitaetId,
+ bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
+ betreuteId: gelesen.data.betreuteId,
+ userId: benutzer.id,
+ userEmail: benutzer.email,
+ userName: benutzer.name,
+ })
+
+ revalidatePath('/einstellungen/papierkorb')
+ return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
+}
diff --git a/tests/berichte.test.ts b/tests/berichte.test.ts
index 6f25c0d..089a5d0 100644
--- a/tests/berichte.test.ts
+++ b/tests/berichte.test.ts
@@ -1,665 +1,665 @@
-import { readFileSync } from 'node:fs'
-import path from 'node:path'
-import { describe, expect, it } from 'vitest'
-
-import {
- ERGAENZEN,
- GLIEDERUNG,
- aufzaehlung,
- dauerAusgeschrieben,
- entwurfAlsText,
- jahresberichtEntwurfBauen,
- jahresberichtEntwurfText,
- malAngabe,
- offeneErgaenzungen,
- textZuAbschnitten,
- type JahresberichtQuellen,
-} from '@/lib/berichte/jahresbericht'
-
-import {
- berichtDateiname,
- exportDokumentPfad,
- pfadIstSicher,
- sichereEndung,
- sichererDateiname,
- belegmappeSchema,
- unterlagenpaketSchema,
- jahresberichtErzeugenSchema,
-} from '@/lib/validierung/berichte'
-
-import {
- HinweisdateiSchema,
- ausloeserPasst,
- hinweiseAusDaten,
- hinweiseAusText,
- hinweiseSuchen,
- passendeHinweise,
- type Hinweiskarte,
-} from '@/lib/hinweise/laden'
-
-import {
- beschreibbareModule,
- freigabeAenderung,
- freigabenNormalisieren,
- letzterAdminGeschuetzt,
- sichtbareModule,
-} from '@/lib/validierung/freigaben'
-
-import { darf } from '@/lib/auth/rollen'
-
-// -----------------------------------------------------------------------------
-// Beispieldaten - ein Jahr im Leben einer betreuten Person
-// -----------------------------------------------------------------------------
-
-const tag = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
-
-function beispiel(aenderungen: Partial = {}): JahresberichtQuellen {
- const quellen: JahresberichtQuellen = {
- betreute: {
- anrede: 'Frau',
- vorname: 'Maria',
- nachname: 'Musterfrau',
- geburtsdatum: tag('1943-04-17'),
- heimName: 'Seniorenzentrum St. Anna',
- heimStrasse: 'Klinikweg 4',
- heimPlz: '95444',
- heimOrt: 'Bayreuth',
- heimZimmer: '212',
- heimEinzugAm: tag('2025-11-03'),
- fruehereStrasse: 'Bergstraße 12',
- frueherePlz: '95463',
- fruehererOrt: 'Bindlach',
- pflegegrad: 4,
- pflegegradSeit: tag('2025-12-01'),
- schwerbehinderungGdB: 80,
- schwerbehinderungMerkzeichen: 'G, B',
- diagnosen: 'COPD GOLD III mit Sauerstoffpflicht, Herzinsuffizienz',
- ermittelterWille: 'Sie möchte nicht wiederbelebt werden und keine künstliche Ernährung.',
- patientenverfuegungVorhanden: true,
- vorsorgevollmachtVorhanden: false,
- aufgabenkreise: ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE'],
- aufgabenkreiseFreitext: null,
- betreuungsgericht: 'Amtsgericht Bayreuth',
- aktenzeichenGericht: 'XVII 123/25',
- bestellungAm: tag('2025-10-01'),
- bestellungBis: null,
- },
- zeitraumVon: tag('2025-10-01'),
- zeitraumBis: tag('2026-09-30'),
- kontakte: [
- {
- datum: tag('2025-11-10'),
- art: 'BESUCH',
- mitBetreuter: true,
- dauerMinuten: 90,
- gespraechspartner: null,
- wuensche: 'Sie möchte ihre Nähmaschine im Zimmer haben.',
- },
- {
- datum: tag('2026-02-14'),
- art: 'BESUCH',
- mitBetreuter: true,
- dauerMinuten: 60,
- gespraechspartner: null,
- wuensche: null,
- },
- {
- datum: tag('2026-06-20'),
- art: 'TELEFONAT',
- mitBetreuter: true,
- dauerMinuten: 15,
- gespraechspartner: null,
- wuensche: 'Das Haus in Bindlach soll nicht verkauft werden.',
- },
- {
- datum: tag('2026-03-02'),
- art: 'TELEFONAT',
- mitBetreuter: false,
- dauerMinuten: 20,
- gespraechspartner: 'Bezirk Oberfranken',
- wuensche: null,
- },
- ],
- gesundheit: [
- {
- datum: tag('2026-01-08'),
- bisDatum: tag('2026-01-19'),
- art: 'KRANKENHAUS',
- titel: 'Exazerbation der COPD',
- einrichtung: 'Klinikum Bayreuth',
- abwesenheitstage: 11,
- },
- {
- datum: tag('2026-04-03'),
- bisDatum: null,
- art: 'ARZTBESUCH',
- titel: 'Kontrolle Lungenfacharzt',
- einrichtung: null,
- abwesenheitstage: null,
- },
- ],
- vorgaenge: [
- {
- titel: 'Antrag auf Hilfe zur Pflege',
- kategorie: 'SOZIALHILFE',
- status: 'WARTET_AUF_DRITTE',
- naechsterSchritt: 'Sachstandsanfrage beim Bezirk',
- gegenstelle: 'Bezirk Oberfranken',
- genehmigungspflichtig: false,
- erledigtAm: null,
- },
- {
- titel: 'Wohnung Bindlach auflösen',
- kategorie: 'IMMOBILIE',
- status: 'ERLEDIGT',
- naechsterSchritt: null,
- gegenstelle: null,
- genehmigungspflichtig: true,
- erledigtAm: tag('2026-05-12'),
- },
- ],
- }
- return { ...quellen, ...aenderungen }
-}
-
-// -----------------------------------------------------------------------------
-// Jahresbericht
-// -----------------------------------------------------------------------------
-
-describe('Jahresbericht nach § 1863 BGB', () => {
- it('führt alle fünf Punkte auf, die das Gericht sehen will', () => {
- const abschnitte = jahresberichtEntwurfBauen(beispiel())
-
- expect(abschnitte).toHaveLength(5)
- expect(abschnitte.map((a) => a.nummer)).toEqual([1, 2, 3, 4, 5])
- expect(abschnitte.map((a) => a.titel)).toEqual(GLIEDERUNG.map((g) => g.titel))
- for (const abschnitt of abschnitte) {
- expect(abschnitt.absaetze.length).toBeGreaterThan(0)
- expect(abschnitt.rechtsgrundlage).toMatch(/§/)
- }
- })
-
- it('beschreibt den Aufenthalt, den Pflegegrad und den Krankenhausaufenthalt', () => {
- const [persoenlich] = jahresberichtEntwurfBauen(beispiel())
- const text = persoenlich!.absaetze.join('\n')
-
- expect(text).toContain('Seniorenzentrum St. Anna')
- expect(text).toContain('Zimmer 212')
- expect(text).toContain('Pflegegrad 4')
- expect(text).toContain('COPD GOLD III')
- expect(text).toContain('Klinikum Bayreuth')
- expect(text).toContain('11 Abwesenheitstage')
- expect(text).toContain('Patientenverfügung liegt vor')
- })
-
- it('zählt die persönlichen Kontakte und schreibt sie in ganzen Sätzen aus', () => {
- const [, kontakt] = jahresberichtEntwurfBauen(beispiel())
- const text = kontakt!.absaetze.join('\n')
-
- // Drei Kontakte mit der betreuten Person, davon zwei Besuche.
- expect(text).toContain('dreimal persönlichen Kontakt')
- expect(text).toContain('2 Besuche')
- expect(text).toContain('ein Telefonat')
- // Der Kontakt mit dem Bezirk zählt nicht als persönlicher Kontakt.
- expect(text).toContain('1 Kontakte mit Dritten')
- expect(text).toContain('Bezirk Oberfranken')
- expect(text).toContain('2 Stunden und 45 Minuten')
- })
-
- it('warnt ausdrücklich, wenn kein persönlicher Kontakt protokolliert ist', () => {
- const ohne = beispiel({ kontakte: [] })
- const [, kontakt] = jahresberichtEntwurfBauen(ohne)
- const text = kontakt!.absaetze.join('\n')
-
- expect(text).toContain('kein persönlicher Kontakt')
- expect(text).toContain('§ 1863 Abs. 2 BGB')
- })
-
- it('übernimmt die Wünsche aus den Protokollen und nennt § 1821 BGB', () => {
- const [, , wuensche] = jahresberichtEntwurfBauen(beispiel())
- const text = wuensche!.absaetze.join('\n')
-
- expect(text).toContain('§ 1821 BGB')
- expect(text).toContain('Nähmaschine')
- expect(text).toContain('Das Haus in Bindlach soll nicht verkauft werden.')
- expect(text).toContain('nicht wiederbelebt werden')
- // Der Betreuer muss selbst ergänzen, wie er den Wünschen entsprochen hat.
- expect(text).toContain(ERGAENZEN)
- })
-
- it('trennt erledigte und laufende Angelegenheiten nach Kategorien', () => {
- const [, , , angelegenheiten] = jahresberichtEntwurfBauen(beispiel())
- const text = angelegenheiten!.absaetze.join('\n')
-
- expect(text).toContain('Sozialhilfe und Hilfe zur Pflege')
- expect(text).toContain('Immobilie und Wohnraum')
- expect(text).toContain('Abgeschlossen wurde Wohnung Bindlach auflösen')
- expect(text).toContain('Noch offen ist Antrag auf Hilfe zur Pflege')
- expect(text).toContain('Sachstandsanfrage beim Bezirk')
- expect(text).toContain('Genehmigungspflichtig waren dabei')
- })
-
- it('macht zur Fortführung einen Vorschlag und verlangt eine eigene Entscheidung', () => {
- const [, , , , einschaetzung] = jahresberichtEntwurfBauen(beispiel())
- const text = einschaetzung!.absaetze.join('\n')
-
- expect(text).toContain('Vermögenssorge')
- expect(text).toContain('Gesundheitssorge')
- expect(text).toContain('fortzuführen')
- expect(text).toContain('zu erweitern')
- expect(text).toContain('einzuschränken')
- expect(text).toContain(ERGAENZEN)
- })
-
- it('bleibt ein Entwurf: offene Stellen sind gezählt und wiederauffindbar', () => {
- const text = jahresberichtEntwurfText(beispiel())
- expect(offeneErgaenzungen(text)).toBeGreaterThan(0)
- })
-
- it('lässt sich als Text ablegen und wieder in Abschnitte zerlegen', () => {
- const abschnitte = jahresberichtEntwurfBauen(beispiel())
- const text = entwurfAlsText(abschnitte)
-
- expect(text.startsWith('1. Persönliche Verhältnisse')).toBe(true)
-
- const zurueck = textZuAbschnitten(text)
- expect(zurueck).toHaveLength(5)
- expect(zurueck[1]!.titel).toBe('2. Persönlicher Kontakt zur betreuten Person')
- expect(zurueck[4]!.absaetze.length).toBeGreaterThan(0)
- })
-
- it('verkraftet einen leeren Bestand, ohne den Bericht zu verstümmeln', () => {
- const leer = beispiel({ kontakte: [], gesundheit: [], vorgaenge: [] })
- const abschnitte = jahresberichtEntwurfBauen(leer)
-
- expect(abschnitte).toHaveLength(5)
- for (const abschnitt of abschnitte) {
- expect(abschnitt.absaetze.join('').trim()).not.toBe('')
- }
- })
-
- it('formuliert Zahl- und Zeitangaben so, wie man sie schreiben würde', () => {
- expect(malAngabe(0)).toBe('keinmal')
- expect(malAngabe(1)).toBe('einmal')
- expect(malAngabe(3)).toBe('dreimal')
- expect(malAngabe(17)).toBe('17-mal')
- expect(dauerAusgeschrieben(95)).toBe('1 Stunde und 35 Minuten')
- expect(dauerAusgeschrieben(45)).toBe('45 Minuten')
- expect(aufzaehlung(['a', 'b', 'c'])).toBe('a, b und c')
- expect(aufzaehlung(['a'])).toBe('a')
- expect(aufzaehlung([])).toBe('')
- })
-})
-
-// -----------------------------------------------------------------------------
-// Dateinamen im Export
-// -----------------------------------------------------------------------------
-
-describe('Dateinamen im Vollexport', () => {
- it('schreibt Umlaute um und lässt nur harmlose Zeichen stehen', () => {
- expect(sichererDateiname('Bescheid Hilfe zur Pflege')).toBe('Bescheid-Hilfe-zur-Pflege')
- expect(sichererDateiname('Vergütung & Rückkauf')).toBe('Verguetung-Rueckkauf')
- expect(sichererDateiname('Maßnahme')).toBe('Massnahme')
- expect(sichererDateiname(' ')).toBe('ohne-titel')
- expect(sichererDateiname('...')).toBe('ohne-titel')
- })
-
- it('lässt keinen Pfadausbruch zu', () => {
- for (const boesartig of [
- '../../etc/passwd',
- '..\\..\\windows\\system32',
- 'C:\\Users\\Public\\x',
- '/etc/shadow',
- 'beleg\u0000.pdf',
- 'auszug:stream',
- ]) {
- const sauber = sichererDateiname(boesartig)
- expect(sauber).not.toContain('..')
- expect(sauber).not.toContain('/')
- expect(sauber).not.toContain('\\')
- expect(sauber).not.toContain(':')
- expect(sauber).not.toContain('\u0000')
- }
- })
-
- it('umgeht Namen, die Windows nie akzeptiert', () => {
- expect(sichererDateiname('CON')).toBe('_CON')
- expect(sichererDateiname('lpt1')).toBe('_lpt1')
- })
-
- it('baut den Dokumentpfad nach dem verabredeten Muster', () => {
- const pfad = exportDokumentPfad({
- datum: tag('2026-03-14'),
- absender: 'Bezirk Oberfranken',
- titel: 'Bescheid über Hilfe zur Pflege',
- dateiname: 'scan.PDF',
- mimeTyp: 'application/pdf',
- })
-
- expect(pfad).toBe(
- 'dokumente/2026/2026-03-14_Bezirk-Oberfranken_Bescheid-ueber-Hilfe-zur-Pflege.pdf',
- )
- expect(pfadIstSicher(pfad)).toBe(true)
- })
-
- it('legt Dokumente ohne Datum in einen eigenen Ordner statt in ein erfundenes Jahr', () => {
- const pfad = exportDokumentPfad({
- datum: null,
- absender: null,
- titel: '../../geheim',
- dateiname: null,
- mimeTyp: 'image/jpeg',
- })
-
- expect(pfad.startsWith('dokumente/ohne-jahr/')).toBe(true)
- expect(pfad.endsWith('.jpg')).toBe(true)
- expect(pfad.split('/')).toHaveLength(3)
- expect(pfadIstSicher(pfad)).toBe(true)
- })
-
- it('erkennt unzulässige Archivpfade', () => {
- expect(pfadIstSicher('daten/frist.json')).toBe(true)
- expect(pfadIstSicher('dokumente/../../x')).toBe(false)
- expect(pfadIstSicher('/absolut')).toBe(false)
- expect(pfadIstSicher('C:/windows')).toBe(false)
- expect(pfadIstSicher('a\\b')).toBe(false)
- expect(pfadIstSicher('')).toBe(false)
- expect(pfadIstSicher('a//b')).toBe(false)
- })
-
- it('normalisiert Dateiendungen', () => {
- expect(sichereEndung('scan.PDF')).toBe('pdf')
- expect(sichereEndung('pdf')).toBe('pdf')
- expect(sichereEndung('ohnepunkt')).toBe('bin')
- expect(sichereEndung('bild.jpeg')).toBe('jpeg')
- expect(sichereEndung('x.verylongextension')).toBe('bin')
- expect(sichereEndung(null)).toBe('bin')
- })
-
- it('benennt erzeugte Berichte sortierbar', () => {
- expect(berichtDateiname('jahresbericht', 'Musterfrau', tag('2026-09-30'), 'pdf')).toBe(
- '2026-09-30_jahresbericht_Musterfrau.pdf',
- )
- })
-})
-
-// -----------------------------------------------------------------------------
-// Eingabepruefung
-// -----------------------------------------------------------------------------
-
-describe('Eingabeprüfung der Berichtsformulare', () => {
- it('weist einen Berichtszeitraum zurück, der rückwärts läuft', () => {
- const ergebnis = jahresberichtErzeugenSchema.safeParse({
- betreuteId: 'abc123',
- zeitraumVon: '2026-09-30',
- zeitraumBis: '2025-10-01',
- })
- expect(ergebnis.success).toBe(false)
- })
-
- it('verlangt für die Belegmappe entweder einen Zeitraum oder einen Vorgang', () => {
- expect(
- belegmappeSchema.safeParse({ betreuteId: 'abc', quelle: 'zeitraum', zeitraumVon: '', zeitraumBis: '' })
- .success,
- ).toBe(false)
- expect(
- belegmappeSchema.safeParse({
- betreuteId: 'abc',
- quelle: 'vorgang',
- zeitraumVon: '',
- zeitraumBis: '',
- vorgangId: 'vg1',
- }).success,
- ).toBe(true)
- })
-
- it('lässt ein Unterlagenpaket ohne ausgewählte Unterlage nicht zu', () => {
- expect(
- unterlagenpaketSchema.safeParse({ betreuteId: 'a', antragId: 'b', nachforderungIds: [] })
- .success,
- ).toBe(false)
- })
-})
-
-// -----------------------------------------------------------------------------
-// Hinweis-Wissensbasis
-// -----------------------------------------------------------------------------
-
-const WISSENSBASIS = path.join(process.cwd(), 'daten', 'hinweise.json')
-
-describe('Hinweis-Wissensbasis', () => {
- const roh = readFileSync(WISSENSBASIS, 'utf8')
- const geladen = hinweiseAusText(roh)
-
- it('ist gültig gegen das Schema', () => {
- expect(geladen.fehler).toEqual([])
- expect(HinweisdateiSchema.safeParse(JSON.parse(roh)).success).toBe(true)
- })
-
- it('enthält mindestens 25 Karten mit eindeutigen Schlüsseln', () => {
- expect(geladen.karten.length).toBeGreaterThanOrEqual(25)
- const schluessel = geladen.karten.map((k) => k.schluessel)
- expect(new Set(schluessel).size).toBe(schluessel.length)
- })
-
- it('nennt zu jeder Karte eine Rechtsgrundlage und einen sicheren Link', () => {
- for (const karte of geladen.karten) {
- expect(karte.rechtsgrundlage.length).toBeGreaterThan(1)
- expect(karte.link.startsWith('https://')).toBe(true)
- expect(karte.kontext.length).toBeGreaterThan(0)
- }
- })
-
- it('deckt die fachlich unverzichtbaren Themen ab', () => {
- const schluessel = new Set(geladen.karten.map((k) => k.schluessel))
- for (const pflicht of [
- 'wohnraum-aufgeben',
- 'grundstueck-genehmigung',
- 'genehmigung-vor-geschaeft',
- 'aufwandspauschale',
- 'vermoegensverzeichnis-frist',
- 'jahresbericht',
- 'getrennte-konten',
- 'schonvermoegen',
- 'hausgrundstueck-geschuetzt',
- 'bestattungsvorsorge',
- 'darlehen-statt-verwertung',
- 'widerspruchsfrist',
- 'fehlende-rechtsbehelfsbelehrung',
- 'wille-leitschnur',
- 'zwangsmassnahme',
- 'unterbringung',
- 'leistungszuschlag',
- 'zuzahlungsbefreiung',
- 'landespflegegeld',
- 'rundfunkbeitragsbefreiung',
- 'pflegegrad-hoeherstufung',
- 'leerstand-versicherung',
- 'verkehrssicherungspflicht',
- 'schenkungsrueckforderung',
- 'betreuerausweis',
- 'kontoauszuege-aufbewahren',
- 'rechtspfleger-fragen',
- ]) {
- expect(schluessel.has(pflicht), `Karte "${pflicht}" fehlt`).toBe(true)
- }
- })
-
- it('markiert die Ausschlussfrist der Aufwandspauschale als solche', () => {
- const karte = geladen.karten.find((k) => k.schluessel === 'aufwandspauschale')!
- expect(karte.text).toContain('Ausschlussfrist')
- expect(karte.rechtsgrundlage).toContain('1878')
- })
-
- it('meldet kaputtes JSON, statt abzustürzen', () => {
- const ergebnis = hinweiseAusText('{ das ist kein JSON')
- expect(ergebnis.karten).toEqual([])
- expect(ergebnis.fehler.length).toBeGreaterThan(0)
- })
-
- it('lehnt Karten ohne Pflichtangaben ab', () => {
- const ergebnis = hinweiseAusDaten({
- version: 1,
- karten: [{ schluessel: 'x', titel: 'zu kurz' }],
- })
- expect(ergebnis.karten).toEqual([])
- expect(ergebnis.fehler.length).toBeGreaterThan(0)
- })
-
- it('erkennt doppelte Schlüssel', () => {
- const eine = geladen.karten[0]!
- const ergebnis = hinweiseAusDaten({ version: 1, karten: [eine, { ...eine }] })
- expect(ergebnis.karten).toEqual([])
- expect(ergebnis.fehler[0]).toContain('Doppelte Schlüssel')
- })
-})
-
-describe('Zuordnung der Hinweiskarten zum Kontext', () => {
- const karten: Hinweiskarte[] = [
- {
- schluessel: 'a',
- titel: 'Karte A für die Immobilie',
- text: 'x'.repeat(60),
- rechtsgrundlage: '§ 1833 BGB',
- link: 'https://www.gesetze-im-internet.de/bgb/__1833.html',
- kontext: ['IMMOBILIE', 'VORLAGE:wohnungsaufloesung'],
- gewicht: 5,
- },
- {
- schluessel: 'b',
- titel: 'Karte B zur Jahresberichtsfrist',
- text: 'y'.repeat(60),
- rechtsgrundlage: '§ 1863 BGB',
- link: 'https://www.gesetze-im-internet.de/bgb/__1863.html',
- kontext: ['FRIST:jahresbericht'],
- gewicht: 9,
- },
- {
- schluessel: 'c',
- titel: 'Karte C zur Sozialhilfe',
- text: 'z'.repeat(60),
- rechtsgrundlage: '§ 90 SGB XII',
- link: 'https://www.gesetze-im-internet.de/sgb_12/__90.html',
- kontext: ['SOZIALHILFE', 'IMMOBILIE'],
- gewicht: 9,
- },
- ]
-
- it('findet Karten über Modul, Vorlage und Frist', () => {
- expect(passendeHinweise(karten, ['IMMOBILIE']).map((k) => k.schluessel)).toEqual(['c', 'a'])
- expect(passendeHinweise(karten, ['VORLAGE:wohnungsaufloesung']).map((k) => k.schluessel)).toEqual([
- 'a',
- ])
- expect(passendeHinweise(karten, ['FRISTEN'])).toEqual([])
- })
-
- it('lässt einen Auslöser auch auf die Frist mit Jahreszahl passen', () => {
- expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresbericht-2027')).toBe(true)
- expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresberichtXYZ')).toBe(false)
- expect(ausloeserPasst('IMMOBILIE', 'immobilie')).toBe(true)
- expect(passendeHinweise(karten, ['FRIST:jahresbericht-2027']).map((k) => k.schluessel)).toEqual([
- 'b',
- ])
- })
-
- it('sortiert nach Gewicht und bleibt bei Gleichstand stabil', () => {
- const treffer = passendeHinweise(karten, ['IMMOBILIE', 'FRIST:jahresbericht'])
- expect(treffer.map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
- expect(passendeHinweise(karten, ['IMMOBILIE'], 1).map((k) => k.schluessel)).toEqual(['c'])
- })
-
- it('sucht über Titel, Text und Rechtsgrundlage', () => {
- expect(hinweiseSuchen(karten, '1833').map((k) => k.schluessel)).toEqual(['a'])
- expect(hinweiseSuchen(karten, 'sozialhilfe').map((k) => k.schluessel)).toEqual(['c'])
- expect(hinweiseSuchen(karten, '').map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
- })
-})
-
-// -----------------------------------------------------------------------------
-// Rollen und Freigaben
-// -----------------------------------------------------------------------------
-
-describe('Mitleser-Rolle und Modulfreigaben', () => {
- const mitleser = {
- rolle: 'MITLESER' as const,
- freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] as const,
- }
-
- it('lässt einen Mitleser niemals schreiben – auch nicht im freigegebenen Modul', () => {
- expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'lesen')).toBe(true)
- expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'schreiben')).toBe(false)
- expect(
- beschreibbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] }),
- ).toEqual([])
- })
-
- it('zeigt einem Mitleser nur die freigegebenen Bereiche', () => {
- expect(sichtbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] })).toEqual([
- 'STAMMDATEN',
- 'FRISTEN',
- ])
- expect(darf({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN'] }, 'GESUNDHEIT', 'lesen')).toBe(
- false,
- )
- })
-
- it('gibt dem Betreuer und dem Administrator alles', () => {
- expect(sichtbareModule({ rolle: 'BETREUER', freigegebeneModule: [] }).length).toBeGreaterThan(10)
- expect(beschreibbareModule({ rolle: 'ADMIN', freigegebeneModule: [] }).length).toBeGreaterThan(10)
- })
-
- it('sperrt jeden Zugriff ohne Zugang', () => {
- expect(darf(null, 'STAMMDATEN', 'lesen')).toBe(false)
- expect(sichtbareModule(null)).toEqual([])
- })
-
- it('räumt die Modulliste auf, sobald jemand kein Mitleser mehr ist', () => {
- expect(freigabenNormalisieren('BETREUER', ['GESUNDHEIT'])).toEqual([])
- expect(freigabenNormalisieren('ADMIN', ['GESUNDHEIT'])).toEqual([])
- expect(freigabenNormalisieren('MITLESER', ['FRISTEN', 'STAMMDATEN'])).toEqual([
- 'STAMMDATEN',
- 'FRISTEN',
- ])
- })
-
- it('benennt, was sich an einer Freigabe ändert – und was davon heikel ist', () => {
- const aenderung = freigabeAenderung(
- { rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] },
- { rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'GESUNDHEIT', 'FINANZEN'] },
- )
-
- expect(aenderung.hinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
- expect(aenderung.entfernt).toEqual(['FRISTEN'])
- expect(aenderung.sensibelHinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
- expect(aenderung.rolleGeaendert).toBe(false)
- })
-
- it('schützt den letzten Administrator vor sich selbst', () => {
- expect(
- letzterAdminGeschuetzt({
- zielRolleVorher: 'ADMIN',
- zielRolleNachher: 'BETREUER',
- zielBleibtAktiv: true,
- anzahlAktiverAdmins: 1,
- }),
- ).toMatch(/letzte aktive Administrator/)
-
- expect(
- letzterAdminGeschuetzt({
- zielRolleVorher: 'ADMIN',
- zielRolleNachher: 'BETREUER',
- zielBleibtAktiv: true,
- anzahlAktiverAdmins: 2,
- }),
- ).toBeNull()
-
- expect(
- letzterAdminGeschuetzt({
- zielRolleVorher: 'BETREUER',
- zielRolleNachher: 'MITLESER',
- zielBleibtAktiv: true,
- anzahlAktiverAdmins: 1,
- }),
- ).toBeNull()
- })
-})
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+import {
+ ERGAENZEN,
+ GLIEDERUNG,
+ aufzaehlung,
+ dauerAusgeschrieben,
+ entwurfAlsText,
+ jahresberichtEntwurfBauen,
+ jahresberichtEntwurfText,
+ malAngabe,
+ offeneErgaenzungen,
+ textZuAbschnitten,
+ type JahresberichtQuellen,
+} from '@/lib/berichte/jahresbericht.entwurf'
+
+import {
+ berichtDateiname,
+ exportDokumentPfad,
+ pfadIstSicher,
+ sichereEndung,
+ sichererDateiname,
+ belegmappeSchema,
+ unterlagenpaketSchema,
+ jahresberichtErzeugenSchema,
+} from '@/lib/validierung/berichte'
+
+import {
+ HinweisdateiSchema,
+ ausloeserPasst,
+ hinweiseAusDaten,
+ hinweiseAusText,
+ hinweiseSuchen,
+ passendeHinweise,
+ type Hinweiskarte,
+} from '@/lib/hinweise/laden'
+
+import {
+ beschreibbareModule,
+ freigabeAenderung,
+ freigabenNormalisieren,
+ letzterAdminGeschuetzt,
+ sichtbareModule,
+} from '@/lib/validierung/freigaben'
+
+import { darf } from '@/lib/auth/rollen'
+
+// -----------------------------------------------------------------------------
+// Beispieldaten - ein Jahr im Leben einer betreuten Person
+// -----------------------------------------------------------------------------
+
+const tag = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
+
+function beispiel(aenderungen: Partial = {}): JahresberichtQuellen {
+ const quellen: JahresberichtQuellen = {
+ betreute: {
+ anrede: 'Frau',
+ vorname: 'Maria',
+ nachname: 'Musterfrau',
+ geburtsdatum: tag('1943-04-17'),
+ heimName: 'Seniorenzentrum St. Anna',
+ heimStrasse: 'Klinikweg 4',
+ heimPlz: '95444',
+ heimOrt: 'Bayreuth',
+ heimZimmer: '212',
+ heimEinzugAm: tag('2025-11-03'),
+ fruehereStrasse: 'Bergstraße 12',
+ frueherePlz: '95463',
+ fruehererOrt: 'Bindlach',
+ pflegegrad: 4,
+ pflegegradSeit: tag('2025-12-01'),
+ schwerbehinderungGdB: 80,
+ schwerbehinderungMerkzeichen: 'G, B',
+ diagnosen: 'COPD GOLD III mit Sauerstoffpflicht, Herzinsuffizienz',
+ ermittelterWille: 'Sie möchte nicht wiederbelebt werden und keine künstliche Ernährung.',
+ patientenverfuegungVorhanden: true,
+ vorsorgevollmachtVorhanden: false,
+ aufgabenkreise: ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE'],
+ aufgabenkreiseFreitext: null,
+ betreuungsgericht: 'Amtsgericht Bayreuth',
+ aktenzeichenGericht: 'XVII 123/25',
+ bestellungAm: tag('2025-10-01'),
+ bestellungBis: null,
+ },
+ zeitraumVon: tag('2025-10-01'),
+ zeitraumBis: tag('2026-09-30'),
+ kontakte: [
+ {
+ datum: tag('2025-11-10'),
+ art: 'BESUCH',
+ mitBetreuter: true,
+ dauerMinuten: 90,
+ gespraechspartner: null,
+ wuensche: 'Sie möchte ihre Nähmaschine im Zimmer haben.',
+ },
+ {
+ datum: tag('2026-02-14'),
+ art: 'BESUCH',
+ mitBetreuter: true,
+ dauerMinuten: 60,
+ gespraechspartner: null,
+ wuensche: null,
+ },
+ {
+ datum: tag('2026-06-20'),
+ art: 'TELEFONAT',
+ mitBetreuter: true,
+ dauerMinuten: 15,
+ gespraechspartner: null,
+ wuensche: 'Das Haus in Bindlach soll nicht verkauft werden.',
+ },
+ {
+ datum: tag('2026-03-02'),
+ art: 'TELEFONAT',
+ mitBetreuter: false,
+ dauerMinuten: 20,
+ gespraechspartner: 'Bezirk Oberfranken',
+ wuensche: null,
+ },
+ ],
+ gesundheit: [
+ {
+ datum: tag('2026-01-08'),
+ bisDatum: tag('2026-01-19'),
+ art: 'KRANKENHAUS',
+ titel: 'Exazerbation der COPD',
+ einrichtung: 'Klinikum Bayreuth',
+ abwesenheitstage: 11,
+ },
+ {
+ datum: tag('2026-04-03'),
+ bisDatum: null,
+ art: 'ARZTBESUCH',
+ titel: 'Kontrolle Lungenfacharzt',
+ einrichtung: null,
+ abwesenheitstage: null,
+ },
+ ],
+ vorgaenge: [
+ {
+ titel: 'Antrag auf Hilfe zur Pflege',
+ kategorie: 'SOZIALHILFE',
+ status: 'WARTET_AUF_DRITTE',
+ naechsterSchritt: 'Sachstandsanfrage beim Bezirk',
+ gegenstelle: 'Bezirk Oberfranken',
+ genehmigungspflichtig: false,
+ erledigtAm: null,
+ },
+ {
+ titel: 'Wohnung Bindlach auflösen',
+ kategorie: 'IMMOBILIE',
+ status: 'ERLEDIGT',
+ naechsterSchritt: null,
+ gegenstelle: null,
+ genehmigungspflichtig: true,
+ erledigtAm: tag('2026-05-12'),
+ },
+ ],
+ }
+ return { ...quellen, ...aenderungen }
+}
+
+// -----------------------------------------------------------------------------
+// Jahresbericht
+// -----------------------------------------------------------------------------
+
+describe('Jahresbericht nach § 1863 BGB', () => {
+ it('führt alle fünf Punkte auf, die das Gericht sehen will', () => {
+ const abschnitte = jahresberichtEntwurfBauen(beispiel())
+
+ expect(abschnitte).toHaveLength(5)
+ expect(abschnitte.map((a) => a.nummer)).toEqual([1, 2, 3, 4, 5])
+ expect(abschnitte.map((a) => a.titel)).toEqual(GLIEDERUNG.map((g) => g.titel))
+ for (const abschnitt of abschnitte) {
+ expect(abschnitt.absaetze.length).toBeGreaterThan(0)
+ expect(abschnitt.rechtsgrundlage).toMatch(/§/)
+ }
+ })
+
+ it('beschreibt den Aufenthalt, den Pflegegrad und den Krankenhausaufenthalt', () => {
+ const [persoenlich] = jahresberichtEntwurfBauen(beispiel())
+ const text = persoenlich!.absaetze.join('\n')
+
+ expect(text).toContain('Seniorenzentrum St. Anna')
+ expect(text).toContain('Zimmer 212')
+ expect(text).toContain('Pflegegrad 4')
+ expect(text).toContain('COPD GOLD III')
+ expect(text).toContain('Klinikum Bayreuth')
+ expect(text).toContain('11 Abwesenheitstage')
+ expect(text).toContain('Patientenverfügung liegt vor')
+ })
+
+ it('zählt die persönlichen Kontakte und schreibt sie in ganzen Sätzen aus', () => {
+ const [, kontakt] = jahresberichtEntwurfBauen(beispiel())
+ const text = kontakt!.absaetze.join('\n')
+
+ // Drei Kontakte mit der betreuten Person, davon zwei Besuche.
+ expect(text).toContain('dreimal persönlichen Kontakt')
+ expect(text).toContain('2 Besuche')
+ expect(text).toContain('ein Telefonat')
+ // Der Kontakt mit dem Bezirk zählt nicht als persönlicher Kontakt.
+ expect(text).toContain('1 Kontakte mit Dritten')
+ expect(text).toContain('Bezirk Oberfranken')
+ expect(text).toContain('2 Stunden und 45 Minuten')
+ })
+
+ it('warnt ausdrücklich, wenn kein persönlicher Kontakt protokolliert ist', () => {
+ const ohne = beispiel({ kontakte: [] })
+ const [, kontakt] = jahresberichtEntwurfBauen(ohne)
+ const text = kontakt!.absaetze.join('\n')
+
+ expect(text).toContain('kein persönlicher Kontakt')
+ expect(text).toContain('§ 1863 Abs. 2 BGB')
+ })
+
+ it('übernimmt die Wünsche aus den Protokollen und nennt § 1821 BGB', () => {
+ const [, , wuensche] = jahresberichtEntwurfBauen(beispiel())
+ const text = wuensche!.absaetze.join('\n')
+
+ expect(text).toContain('§ 1821 BGB')
+ expect(text).toContain('Nähmaschine')
+ expect(text).toContain('Das Haus in Bindlach soll nicht verkauft werden.')
+ expect(text).toContain('nicht wiederbelebt werden')
+ // Der Betreuer muss selbst ergänzen, wie er den Wünschen entsprochen hat.
+ expect(text).toContain(ERGAENZEN)
+ })
+
+ it('trennt erledigte und laufende Angelegenheiten nach Kategorien', () => {
+ const [, , , angelegenheiten] = jahresberichtEntwurfBauen(beispiel())
+ const text = angelegenheiten!.absaetze.join('\n')
+
+ expect(text).toContain('Sozialhilfe und Hilfe zur Pflege')
+ expect(text).toContain('Immobilie und Wohnraum')
+ expect(text).toContain('Abgeschlossen wurde Wohnung Bindlach auflösen')
+ expect(text).toContain('Noch offen ist Antrag auf Hilfe zur Pflege')
+ expect(text).toContain('Sachstandsanfrage beim Bezirk')
+ expect(text).toContain('Genehmigungspflichtig waren dabei')
+ })
+
+ it('macht zur Fortführung einen Vorschlag und verlangt eine eigene Entscheidung', () => {
+ const [, , , , einschaetzung] = jahresberichtEntwurfBauen(beispiel())
+ const text = einschaetzung!.absaetze.join('\n')
+
+ expect(text).toContain('Vermögenssorge')
+ expect(text).toContain('Gesundheitssorge')
+ expect(text).toContain('fortzuführen')
+ expect(text).toContain('zu erweitern')
+ expect(text).toContain('einzuschränken')
+ expect(text).toContain(ERGAENZEN)
+ })
+
+ it('bleibt ein Entwurf: offene Stellen sind gezählt und wiederauffindbar', () => {
+ const text = jahresberichtEntwurfText(beispiel())
+ expect(offeneErgaenzungen(text)).toBeGreaterThan(0)
+ })
+
+ it('lässt sich als Text ablegen und wieder in Abschnitte zerlegen', () => {
+ const abschnitte = jahresberichtEntwurfBauen(beispiel())
+ const text = entwurfAlsText(abschnitte)
+
+ expect(text.startsWith('1. Persönliche Verhältnisse')).toBe(true)
+
+ const zurueck = textZuAbschnitten(text)
+ expect(zurueck).toHaveLength(5)
+ expect(zurueck[1]!.titel).toBe('2. Persönlicher Kontakt zur betreuten Person')
+ expect(zurueck[4]!.absaetze.length).toBeGreaterThan(0)
+ })
+
+ it('verkraftet einen leeren Bestand, ohne den Bericht zu verstümmeln', () => {
+ const leer = beispiel({ kontakte: [], gesundheit: [], vorgaenge: [] })
+ const abschnitte = jahresberichtEntwurfBauen(leer)
+
+ expect(abschnitte).toHaveLength(5)
+ for (const abschnitt of abschnitte) {
+ expect(abschnitt.absaetze.join('').trim()).not.toBe('')
+ }
+ })
+
+ it('formuliert Zahl- und Zeitangaben so, wie man sie schreiben würde', () => {
+ expect(malAngabe(0)).toBe('keinmal')
+ expect(malAngabe(1)).toBe('einmal')
+ expect(malAngabe(3)).toBe('dreimal')
+ expect(malAngabe(17)).toBe('17-mal')
+ expect(dauerAusgeschrieben(95)).toBe('1 Stunde und 35 Minuten')
+ expect(dauerAusgeschrieben(45)).toBe('45 Minuten')
+ expect(aufzaehlung(['a', 'b', 'c'])).toBe('a, b und c')
+ expect(aufzaehlung(['a'])).toBe('a')
+ expect(aufzaehlung([])).toBe('')
+ })
+})
+
+// -----------------------------------------------------------------------------
+// Dateinamen im Export
+// -----------------------------------------------------------------------------
+
+describe('Dateinamen im Vollexport', () => {
+ it('schreibt Umlaute um und lässt nur harmlose Zeichen stehen', () => {
+ expect(sichererDateiname('Bescheid Hilfe zur Pflege')).toBe('Bescheid-Hilfe-zur-Pflege')
+ expect(sichererDateiname('Vergütung & Rückkauf')).toBe('Verguetung-Rueckkauf')
+ expect(sichererDateiname('Maßnahme')).toBe('Massnahme')
+ expect(sichererDateiname(' ')).toBe('ohne-titel')
+ expect(sichererDateiname('...')).toBe('ohne-titel')
+ })
+
+ it('lässt keinen Pfadausbruch zu', () => {
+ for (const boesartig of [
+ '../../etc/passwd',
+ '..\\..\\windows\\system32',
+ 'C:\\Users\\Public\\x',
+ '/etc/shadow',
+ 'beleg\u0000.pdf',
+ 'auszug:stream',
+ ]) {
+ const sauber = sichererDateiname(boesartig)
+ expect(sauber).not.toContain('..')
+ expect(sauber).not.toContain('/')
+ expect(sauber).not.toContain('\\')
+ expect(sauber).not.toContain(':')
+ expect(sauber).not.toContain('\u0000')
+ }
+ })
+
+ it('umgeht Namen, die Windows nie akzeptiert', () => {
+ expect(sichererDateiname('CON')).toBe('_CON')
+ expect(sichererDateiname('lpt1')).toBe('_lpt1')
+ })
+
+ it('baut den Dokumentpfad nach dem verabredeten Muster', () => {
+ const pfad = exportDokumentPfad({
+ datum: tag('2026-03-14'),
+ absender: 'Bezirk Oberfranken',
+ titel: 'Bescheid über Hilfe zur Pflege',
+ dateiname: 'scan.PDF',
+ mimeTyp: 'application/pdf',
+ })
+
+ expect(pfad).toBe(
+ 'dokumente/2026/2026-03-14_Bezirk-Oberfranken_Bescheid-ueber-Hilfe-zur-Pflege.pdf',
+ )
+ expect(pfadIstSicher(pfad)).toBe(true)
+ })
+
+ it('legt Dokumente ohne Datum in einen eigenen Ordner statt in ein erfundenes Jahr', () => {
+ const pfad = exportDokumentPfad({
+ datum: null,
+ absender: null,
+ titel: '../../geheim',
+ dateiname: null,
+ mimeTyp: 'image/jpeg',
+ })
+
+ expect(pfad.startsWith('dokumente/ohne-jahr/')).toBe(true)
+ expect(pfad.endsWith('.jpg')).toBe(true)
+ expect(pfad.split('/')).toHaveLength(3)
+ expect(pfadIstSicher(pfad)).toBe(true)
+ })
+
+ it('erkennt unzulässige Archivpfade', () => {
+ expect(pfadIstSicher('daten/frist.json')).toBe(true)
+ expect(pfadIstSicher('dokumente/../../x')).toBe(false)
+ expect(pfadIstSicher('/absolut')).toBe(false)
+ expect(pfadIstSicher('C:/windows')).toBe(false)
+ expect(pfadIstSicher('a\\b')).toBe(false)
+ expect(pfadIstSicher('')).toBe(false)
+ expect(pfadIstSicher('a//b')).toBe(false)
+ })
+
+ it('normalisiert Dateiendungen', () => {
+ expect(sichereEndung('scan.PDF')).toBe('pdf')
+ expect(sichereEndung('pdf')).toBe('pdf')
+ expect(sichereEndung('ohnepunkt')).toBe('bin')
+ expect(sichereEndung('bild.jpeg')).toBe('jpeg')
+ expect(sichereEndung('x.verylongextension')).toBe('bin')
+ expect(sichereEndung(null)).toBe('bin')
+ })
+
+ it('benennt erzeugte Berichte sortierbar', () => {
+ expect(berichtDateiname('jahresbericht', 'Musterfrau', tag('2026-09-30'), 'pdf')).toBe(
+ '2026-09-30_jahresbericht_Musterfrau.pdf',
+ )
+ })
+})
+
+// -----------------------------------------------------------------------------
+// Eingabepruefung
+// -----------------------------------------------------------------------------
+
+describe('Eingabeprüfung der Berichtsformulare', () => {
+ it('weist einen Berichtszeitraum zurück, der rückwärts läuft', () => {
+ const ergebnis = jahresberichtErzeugenSchema.safeParse({
+ betreuteId: 'abc123',
+ zeitraumVon: '2026-09-30',
+ zeitraumBis: '2025-10-01',
+ })
+ expect(ergebnis.success).toBe(false)
+ })
+
+ it('verlangt für die Belegmappe entweder einen Zeitraum oder einen Vorgang', () => {
+ expect(
+ belegmappeSchema.safeParse({ betreuteId: 'abc', quelle: 'zeitraum', zeitraumVon: '', zeitraumBis: '' })
+ .success,
+ ).toBe(false)
+ expect(
+ belegmappeSchema.safeParse({
+ betreuteId: 'abc',
+ quelle: 'vorgang',
+ zeitraumVon: '',
+ zeitraumBis: '',
+ vorgangId: 'vg1',
+ }).success,
+ ).toBe(true)
+ })
+
+ it('lässt ein Unterlagenpaket ohne ausgewählte Unterlage nicht zu', () => {
+ expect(
+ unterlagenpaketSchema.safeParse({ betreuteId: 'a', antragId: 'b', nachforderungIds: [] })
+ .success,
+ ).toBe(false)
+ })
+})
+
+// -----------------------------------------------------------------------------
+// Hinweis-Wissensbasis
+// -----------------------------------------------------------------------------
+
+const WISSENSBASIS = path.join(process.cwd(), 'daten', 'hinweise.json')
+
+describe('Hinweis-Wissensbasis', () => {
+ const roh = readFileSync(WISSENSBASIS, 'utf8')
+ const geladen = hinweiseAusText(roh)
+
+ it('ist gültig gegen das Schema', () => {
+ expect(geladen.fehler).toEqual([])
+ expect(HinweisdateiSchema.safeParse(JSON.parse(roh)).success).toBe(true)
+ })
+
+ it('enthält mindestens 25 Karten mit eindeutigen Schlüsseln', () => {
+ expect(geladen.karten.length).toBeGreaterThanOrEqual(25)
+ const schluessel = geladen.karten.map((k) => k.schluessel)
+ expect(new Set(schluessel).size).toBe(schluessel.length)
+ })
+
+ it('nennt zu jeder Karte eine Rechtsgrundlage und einen sicheren Link', () => {
+ for (const karte of geladen.karten) {
+ expect(karte.rechtsgrundlage.length).toBeGreaterThan(1)
+ expect(karte.link.startsWith('https://')).toBe(true)
+ expect(karte.kontext.length).toBeGreaterThan(0)
+ }
+ })
+
+ it('deckt die fachlich unverzichtbaren Themen ab', () => {
+ const schluessel = new Set(geladen.karten.map((k) => k.schluessel))
+ for (const pflicht of [
+ 'wohnraum-aufgeben',
+ 'grundstueck-genehmigung',
+ 'genehmigung-vor-geschaeft',
+ 'aufwandspauschale',
+ 'vermoegensverzeichnis-frist',
+ 'jahresbericht',
+ 'getrennte-konten',
+ 'schonvermoegen',
+ 'hausgrundstueck-geschuetzt',
+ 'bestattungsvorsorge',
+ 'darlehen-statt-verwertung',
+ 'widerspruchsfrist',
+ 'fehlende-rechtsbehelfsbelehrung',
+ 'wille-leitschnur',
+ 'zwangsmassnahme',
+ 'unterbringung',
+ 'leistungszuschlag',
+ 'zuzahlungsbefreiung',
+ 'landespflegegeld',
+ 'rundfunkbeitragsbefreiung',
+ 'pflegegrad-hoeherstufung',
+ 'leerstand-versicherung',
+ 'verkehrssicherungspflicht',
+ 'schenkungsrueckforderung',
+ 'betreuerausweis',
+ 'kontoauszuege-aufbewahren',
+ 'rechtspfleger-fragen',
+ ]) {
+ expect(schluessel.has(pflicht), `Karte "${pflicht}" fehlt`).toBe(true)
+ }
+ })
+
+ it('markiert die Ausschlussfrist der Aufwandspauschale als solche', () => {
+ const karte = geladen.karten.find((k) => k.schluessel === 'aufwandspauschale')!
+ expect(karte.text).toContain('Ausschlussfrist')
+ expect(karte.rechtsgrundlage).toContain('1878')
+ })
+
+ it('meldet kaputtes JSON, statt abzustürzen', () => {
+ const ergebnis = hinweiseAusText('{ das ist kein JSON')
+ expect(ergebnis.karten).toEqual([])
+ expect(ergebnis.fehler.length).toBeGreaterThan(0)
+ })
+
+ it('lehnt Karten ohne Pflichtangaben ab', () => {
+ const ergebnis = hinweiseAusDaten({
+ version: 1,
+ karten: [{ schluessel: 'x', titel: 'zu kurz' }],
+ })
+ expect(ergebnis.karten).toEqual([])
+ expect(ergebnis.fehler.length).toBeGreaterThan(0)
+ })
+
+ it('erkennt doppelte Schlüssel', () => {
+ const eine = geladen.karten[0]!
+ const ergebnis = hinweiseAusDaten({ version: 1, karten: [eine, { ...eine }] })
+ expect(ergebnis.karten).toEqual([])
+ expect(ergebnis.fehler[0]).toContain('Doppelte Schlüssel')
+ })
+})
+
+describe('Zuordnung der Hinweiskarten zum Kontext', () => {
+ const karten: Hinweiskarte[] = [
+ {
+ schluessel: 'a',
+ titel: 'Karte A für die Immobilie',
+ text: 'x'.repeat(60),
+ rechtsgrundlage: '§ 1833 BGB',
+ link: 'https://www.gesetze-im-internet.de/bgb/__1833.html',
+ kontext: ['IMMOBILIE', 'VORLAGE:wohnungsaufloesung'],
+ gewicht: 5,
+ },
+ {
+ schluessel: 'b',
+ titel: 'Karte B zur Jahresberichtsfrist',
+ text: 'y'.repeat(60),
+ rechtsgrundlage: '§ 1863 BGB',
+ link: 'https://www.gesetze-im-internet.de/bgb/__1863.html',
+ kontext: ['FRIST:jahresbericht'],
+ gewicht: 9,
+ },
+ {
+ schluessel: 'c',
+ titel: 'Karte C zur Sozialhilfe',
+ text: 'z'.repeat(60),
+ rechtsgrundlage: '§ 90 SGB XII',
+ link: 'https://www.gesetze-im-internet.de/sgb_12/__90.html',
+ kontext: ['SOZIALHILFE', 'IMMOBILIE'],
+ gewicht: 9,
+ },
+ ]
+
+ it('findet Karten über Modul, Vorlage und Frist', () => {
+ expect(passendeHinweise(karten, ['IMMOBILIE']).map((k) => k.schluessel)).toEqual(['c', 'a'])
+ expect(passendeHinweise(karten, ['VORLAGE:wohnungsaufloesung']).map((k) => k.schluessel)).toEqual([
+ 'a',
+ ])
+ expect(passendeHinweise(karten, ['FRISTEN'])).toEqual([])
+ })
+
+ it('lässt einen Auslöser auch auf die Frist mit Jahreszahl passen', () => {
+ expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresbericht-2027')).toBe(true)
+ expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresberichtXYZ')).toBe(false)
+ expect(ausloeserPasst('IMMOBILIE', 'immobilie')).toBe(true)
+ expect(passendeHinweise(karten, ['FRIST:jahresbericht-2027']).map((k) => k.schluessel)).toEqual([
+ 'b',
+ ])
+ })
+
+ it('sortiert nach Gewicht und bleibt bei Gleichstand stabil', () => {
+ const treffer = passendeHinweise(karten, ['IMMOBILIE', 'FRIST:jahresbericht'])
+ expect(treffer.map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
+ expect(passendeHinweise(karten, ['IMMOBILIE'], 1).map((k) => k.schluessel)).toEqual(['c'])
+ })
+
+ it('sucht über Titel, Text und Rechtsgrundlage', () => {
+ expect(hinweiseSuchen(karten, '1833').map((k) => k.schluessel)).toEqual(['a'])
+ expect(hinweiseSuchen(karten, 'sozialhilfe').map((k) => k.schluessel)).toEqual(['c'])
+ expect(hinweiseSuchen(karten, '').map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
+ })
+})
+
+// -----------------------------------------------------------------------------
+// Rollen und Freigaben
+// -----------------------------------------------------------------------------
+
+describe('Mitleser-Rolle und Modulfreigaben', () => {
+ const mitleser = {
+ rolle: 'MITLESER' as const,
+ freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] as const,
+ }
+
+ it('lässt einen Mitleser niemals schreiben – auch nicht im freigegebenen Modul', () => {
+ expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'lesen')).toBe(true)
+ expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'schreiben')).toBe(false)
+ expect(
+ beschreibbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] }),
+ ).toEqual([])
+ })
+
+ it('zeigt einem Mitleser nur die freigegebenen Bereiche', () => {
+ expect(sichtbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] })).toEqual([
+ 'STAMMDATEN',
+ 'FRISTEN',
+ ])
+ expect(darf({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN'] }, 'GESUNDHEIT', 'lesen')).toBe(
+ false,
+ )
+ })
+
+ it('gibt dem Betreuer und dem Administrator alles', () => {
+ expect(sichtbareModule({ rolle: 'BETREUER', freigegebeneModule: [] }).length).toBeGreaterThan(10)
+ expect(beschreibbareModule({ rolle: 'ADMIN', freigegebeneModule: [] }).length).toBeGreaterThan(10)
+ })
+
+ it('sperrt jeden Zugriff ohne Zugang', () => {
+ expect(darf(null, 'STAMMDATEN', 'lesen')).toBe(false)
+ expect(sichtbareModule(null)).toEqual([])
+ })
+
+ it('räumt die Modulliste auf, sobald jemand kein Mitleser mehr ist', () => {
+ expect(freigabenNormalisieren('BETREUER', ['GESUNDHEIT'])).toEqual([])
+ expect(freigabenNormalisieren('ADMIN', ['GESUNDHEIT'])).toEqual([])
+ expect(freigabenNormalisieren('MITLESER', ['FRISTEN', 'STAMMDATEN'])).toEqual([
+ 'STAMMDATEN',
+ 'FRISTEN',
+ ])
+ })
+
+ it('benennt, was sich an einer Freigabe ändert – und was davon heikel ist', () => {
+ const aenderung = freigabeAenderung(
+ { rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] },
+ { rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'GESUNDHEIT', 'FINANZEN'] },
+ )
+
+ expect(aenderung.hinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
+ expect(aenderung.entfernt).toEqual(['FRISTEN'])
+ expect(aenderung.sensibelHinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
+ expect(aenderung.rolleGeaendert).toBe(false)
+ })
+
+ it('schützt den letzten Administrator vor sich selbst', () => {
+ expect(
+ letzterAdminGeschuetzt({
+ zielRolleVorher: 'ADMIN',
+ zielRolleNachher: 'BETREUER',
+ zielBleibtAktiv: true,
+ anzahlAktiverAdmins: 1,
+ }),
+ ).toMatch(/letzte aktive Administrator/)
+
+ expect(
+ letzterAdminGeschuetzt({
+ zielRolleVorher: 'ADMIN',
+ zielRolleNachher: 'BETREUER',
+ zielBleibtAktiv: true,
+ anzahlAktiverAdmins: 2,
+ }),
+ ).toBeNull()
+
+ expect(
+ letzterAdminGeschuetzt({
+ zielRolleVorher: 'BETREUER',
+ zielRolleNachher: 'MITLESER',
+ zielBleibtAktiv: true,
+ anzahlAktiverAdmins: 1,
+ }),
+ ).toBeNull()
+ })
+})