feat: Jahresbericht, Vollexport, Wissensbasis und Mitleser-Freigaben

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

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

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

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

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

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

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

41 Tests.

Etappe: 7
Phase: Ausgabe
This commit is contained in:
Florian Wilde
2026-08-22 06:22:44 +02:00
parent b69a12da8f
commit 5cc7f5c140
29 changed files with 5573 additions and 3076 deletions
+213 -213
View File
@@ -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<Buffer> {
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<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
+134
View File
@@ -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<JahresberichtQuellen> {
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<string> {
return jahresberichtEntwurfText(
await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
)
}
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
return pdfErzeugen(createElement(JahresberichtDokument, { kopf, text }))
}
+694
View File
@@ -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<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
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<VorgangKategorie, string> = {
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<Aufgabenkreis, string> = {
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<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): 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<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): 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<JahresberichtBetreute, 'anrede'>): { 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<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
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
}
+89
View File
@@ -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 (
<View>
{abschnitte.map((abschnitt, i) => (
<View key={`abschnitt-${i}`} wrap={true}>
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
{abschnitt.absaetze.map((absatz, j) => (
<Text
key={`absatz-${i}-${j}`}
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
>
{absatz}
</Text>
))}
</View>
))}
</View>
)
}
export function JahresberichtDokument({
kopf,
text,
vorspann,
}: {
kopf: Berichtskopf
text: string
vorspann?: string
}): React.JSX.Element {
return (
<Berichtsdokument kopf={kopf} mitUnterschrift={true}>
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
<JahresberichtInhalt text={text} />
</Berichtsdokument>
)
}
+19 -688
View File
@@ -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<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
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<VorgangKategorie, string> = {
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<Aufgabenkreis, string> = {
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<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): 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<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): 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<JahresberichtBetreute, 'anrede'>): { 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<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
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'
+21 -230
View File
@@ -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<JahresberichtQuellen> {
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<string> {
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 (
<View>
{abschnitte.map((abschnitt, i) => (
<View key={`abschnitt-${i}`} wrap={true}>
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
{abschnitt.absaetze.map((absatz, j) => (
<Text
key={`absatz-${i}-${j}`}
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
>
{absatz}
</Text>
))}
</View>
))}
</View>
)
}
export function JahresberichtDokument({
kopf,
text,
vorspann,
}: {
kopf: Berichtskopf
text: string
vorspann?: string
}) {
return (
<Document title={kopf.titel} author={kopf.erstelltVon} language="de">
<Berichtsseite kopf={kopf} mitUnterschrift={true}>
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
<JahresberichtInhalt text={text} />
</Berichtsseite>
</Document>
)
}
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
return pdfErzeugen(<JahresberichtDokument kopf={kopf} text={text} />)
}
export * from '@/lib/berichte/jahresbericht.entwurf'
export * from '@/lib/berichte/jahresbericht.inhalt'
export * from '@/lib/berichte/jahresbericht.ausgabe'