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:
+213
-213
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user