docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
+213
View File
@@ -0,0 +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))
}
+417
View File
@@ -0,0 +1,417 @@
import 'server-only'
import type { Betreute, User } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import type { Berichtskopf } from '@/lib/pdf/rahmen'
import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
import { ibanMaskieren } from '@/lib/finanzen/iban'
import { betreuungsjahr, type Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { laufendeBewegungen, type BuchungEingabe, type LaufendeBewegungen } from '@/lib/finanzen/kassenbuch'
import {
rechnungslegungBerechnen,
saldoZumStichtag,
type KontoEingabe,
type Rechnungsdaten,
type Variante,
} from '@/lib/finanzen/rechnungslegung'
import { vermoegenBerechnen, type Vermoegensbild } from '@/lib/finanzen/vermoegen'
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
/**
* Gemeinsamer Unterbau der beiden Finanzberichte.
*
* Hier - und nur hier - wird aus der Datenbank gelesen. Die Rechnungen
* selbst stehen in src/lib/finanzen/ und kommen ohne Datenbank aus; die
* Darstellung steht in den beiden .tsx-Dateien daneben. Diese Trennung
* ist der Grund, warum sich jede Zahl im Bericht als Testfall
* nachrechnen laesst.
*/
export type BerichtsBetreute = {
id: string
name: string
geburtsdatum: Date | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
befreiterBetreuer: boolean
}
export function betreuteFuerBericht(betreute: Betreute): BerichtsBetreute {
return {
id: betreute.id,
name: `${betreute.vorname} ${betreute.nachname}`,
geburtsdatum: betreute.geburtsdatum,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: betreute.bestellungAm,
befreiterBetreuer: betreute.befreiterBetreuer,
}
}
/** Kopfzeile nach dem Vertrag aus docs/SCHNITTSTELLEN.md, Abschnitt 2. */
export function berichtskopf(eingabe: {
titel: string
betreute: BerichtsBetreute
rechtsgrundlage: string
erstelltVon: string
stichtag?: Date | null
zeitraumVon?: Date | null
zeitraumBis?: Date | null
erstelltAm?: Date
}): Berichtskopf {
const behoerde = eingabe.betreute.betreuungsgericht
? eingabe.betreute.aktenzeichenGericht
? `${eingabe.betreute.betreuungsgericht} · Az. ${eingabe.betreute.aktenzeichenGericht}`
: eingabe.betreute.betreuungsgericht
: undefined
return {
titel: eingabe.titel,
betreute: { name: eingabe.betreute.name, geburtsdatum: eingabe.betreute.geburtsdatum },
behoerde,
aktenzeichen: eingabe.betreute.aktenzeichenGericht ?? undefined,
zeitraumVon: eingabe.zeitraumVon ?? null,
zeitraumBis: eingabe.zeitraumBis ?? null,
stichtag: eingabe.stichtag ?? null,
rechtsgrundlage: eingabe.rechtsgrundlage,
erstelltAm: eingabe.erstelltAm ?? new Date(),
erstelltVon: eingabe.erstelltVon,
}
}
// -----------------------------------------------------------------------------
// Laden
// -----------------------------------------------------------------------------
/**
* Alle Buchungen einer betreuten Person, entschluesselt und um die
* Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel,
* Betreuerkonto ja/nein).
*/
export async function buchungenLaden(
betreuteId: string,
zeitraum?: { von: Date; bis: Date },
): Promise<BuchungEingabe[]> {
const zeilen = await db.buchung.findMany({
where: {
betreuteId,
...nichtGeloescht,
...(zeitraum
? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } }
: {}),
},
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }],
})
return zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
}
/** Konten mit ihrer vollstaendigen Saldostand-Historie. */
export async function kontenLaden(betreuteId: string): Promise<KontoEingabe[]> {
const zeilen = await db.konto.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { saldostaende: { orderBy: { stichtag: 'asc' } } },
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
})
return zeilen.map((konto) => {
const offen = oeffnen('Konto', konto)
return {
id: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
art: konto.art,
istBetreuerkonto: konto.istBetreuerkonto,
ibanMaskiert: offen.iban ? ibanMaskieren(offen.iban) : null,
saldostaende: konto.saldostaende.map((s) => ({
stichtag: s.stichtag,
betragCent: s.betragCent,
quelle: s.quelle,
})),
}
})
}
export type KontostandZeile = {
kontoId: string
bezeichnung: string
bank: string | null
artBeschriftung: string
istBetreuerkonto: boolean
ibanMaskiert: string | null
betragCent: bigint | null
stichtag: Date | null
quelle: string | null
}
/** Kontostaende zu einem Stichtag - fuer Uebersicht und Verzeichnis. */
export function kontostaendeZumStichtag(
konten: KontoEingabe[],
stichtag: Date,
): KontostandZeile[] {
return konten.map((konto) => {
const saldo = saldoZumStichtag(konto.saldostaende, stichtag)
return {
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
istBetreuerkonto: konto.istBetreuerkonto,
ibanMaskiert: konto.ibanMaskiert ?? null,
betragCent: saldo?.betragCent ?? null,
stichtag: saldo ? alsTag(saldo.stichtag) : null,
quelle: saldo?.quelle ?? null,
}
})
}
/** Vermoegensposten mit ihrer Wert-Historie, gerechnet auf einen Stichtag. */
export async function vermoegenLaden(
betreuteId: string,
stichtag: Date,
): Promise<Vermoegensbild> {
const posten = await db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: {
wertstaende: {
orderBy: { stichtag: 'asc' },
include: { nachweisDokument: { select: { id: true, titel: true } } },
},
},
})
return vermoegenBerechnen(
posten.map((p) => ({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
kontoId: p.kontoId,
immobilieId: p.immobilieId,
vertragId: p.vertragId,
bewertungSozialhilfe: p.bewertungSozialhilfe,
wertstaende: p.wertstaende.map((w) => ({
stichtag: w.stichtag,
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
nachweisTitel: w.nachweisDokument?.titel ?? null,
bemerkung: w.bemerkung,
})),
})),
stichtag,
)
}
// -----------------------------------------------------------------------------
// Vermoegensverzeichnis (§ 1835 BGB)
// -----------------------------------------------------------------------------
export type VermoegensverzeichnisDaten = {
kopf: Berichtskopf
betreute: BerichtsBetreute
stichtag: Date
/** Konten mit ihrem Stand am Stichtag - ohne Konten des Betreuers. */
kontostaende: KontostandZeile[]
summeKontenCent: bigint
/** Uebrige Vermoegensposten. Posten der Art KONTO bleiben aussen vor. */
vermoegen: Vermoegensbild
summeAktivaCent: bigint
summePassivaCent: bigint
reinvermoegenCent: bigint
laufend: LaufendeBewegungen
hinweise: string[]
}
/**
* Konten erscheinen im Verzeichnis mit ihrem Saldostand, nicht als
* Vermoegensposten. Deshalb werden Posten der Art KONTO uebersprungen -
* sonst stuende dasselbe Guthaben zweimal in der Summe.
*/
export async function vermoegensverzeichnisDaten(eingabe: {
betreute: Betreute
stichtag: Date
erstelltVon: string
monateRueckblick?: number
}): Promise<VermoegensverzeichnisDaten> {
const stichtag = alsTag(eingabe.stichtag)
const monate = eingabe.monateRueckblick ?? 12
const betreute = betreuteFuerBericht(eingabe.betreute)
const [konten, alleBuchungen, vermoegenAlle] = await Promise.all([
kontenLaden(betreute.id),
buchungenLaden(betreute.id),
vermoegenLaden(betreute.id, stichtag),
])
const kontostaende = kontostaendeZumStichtag(
konten.filter((k) => !k.istBetreuerkonto),
stichtag,
)
const summeKontenCent = kontostaende.reduce((s, z) => s + (z.betragCent ?? 0n), 0n)
// Posten der Art KONTO werden herausgenommen - siehe Kommentar oben.
const vermoegen: Vermoegensbild = {
...vermoegenAlle,
aktivaGruppen: vermoegenAlle.aktivaGruppen.filter((g) => g.art !== 'KONTO'),
aktiva: vermoegenAlle.aktiva.filter((p) => p.art !== 'KONTO'),
ohneWert: vermoegenAlle.ohneWert.filter((p) => p.art !== 'KONTO'),
summeAktivaCent: vermoegenAlle.aktiva
.filter((p) => p.art !== 'KONTO')
.reduce((s, p) => s + p.wertCent, 0n),
}
const von = tagePlus(monatePlus(stichtag, -monate), 1)
const laufend = laufendeBewegungen(alleBuchungen, von, stichtag, monate)
const summeAktivaCent = summeKontenCent + vermoegen.summeAktivaCent
const summePassivaCent = vermoegen.summePassivaCent
const hinweise: string[] = []
for (const zeile of kontostaende) {
if (zeile.betragCent === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ ist zum Stichtag kein Saldostand erfasst. Das Konto steht ` +
'ohne Betrag im Verzeichnis.',
)
} else if (zeile.stichtag && zeile.stichtag.getTime() !== stichtag.getTime()) {
hinweise.push(
`Der Stand von „${zeile.bezeichnung}“ stammt vom ${kurz(zeile.stichtag)}, nicht vom Stichtag.`,
)
}
}
for (const posten of vermoegen.ohneWert) {
hinweise.push(
`Für „${posten.bezeichnung}“ liegt zum Stichtag keine Bewertung vor. ` +
(posten.spaetereWertstaende > 0
? 'Es gibt nur Wertstände nach dem Stichtag – die dürfen hier nicht verwendet werden.'
: 'Es ist noch kein Wertstand erfasst.'),
)
}
if (vermoegen.aeltesterWertTage !== null && vermoegen.aeltesterWertTage > 365) {
hinweise.push(
`Der älteste verwendete Wert ist ${vermoegen.aeltesterWertTage} Tage alt. ` +
'Vor der Abgabe lohnt sich eine aktuelle Bewertung.',
)
}
return {
kopf: berichtskopf({
titel: 'Vermögensverzeichnis',
betreute,
rechtsgrundlage: '§ 1835 BGB',
erstelltVon: eingabe.erstelltVon,
stichtag,
}),
betreute,
stichtag,
kontostaende,
summeKontenCent,
vermoegen,
summeAktivaCent,
summePassivaCent,
reinvermoegenCent: summeAktivaCent - summePassivaCent,
laufend,
hinweise,
}
}
// -----------------------------------------------------------------------------
// Rechnungslegung (§§ 1859, 1865 BGB)
// -----------------------------------------------------------------------------
export type RechnungslegungDaten = {
kopf: Berichtskopf
betreute: BerichtsBetreute
jahr: Betreuungsjahr
rechnung: Rechnungsdaten
/** Vermoegen am Ende des Zeitraums - gehoert in beide Varianten. */
vermoegenEnde: Vermoegensbild
}
export async function rechnungslegungDaten(eingabe: {
betreute: Betreute
jahrNummer: number
variante: Variante
erstelltVon: string
}): Promise<RechnungslegungDaten> {
if (!eingabe.betreute.bestellungAm) {
throw new Error(
'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. ' +
'Bitte zuerst in den Stammdaten das Datum der Bestellung eintragen.',
)
}
const betreute = betreuteFuerBericht(eingabe.betreute)
const jahr = betreuungsjahr(eingabe.betreute.bestellungAm, eingabe.jahrNummer)
const [konten, buchungen, vermoegenEnde] = await Promise.all([
kontenLaden(betreute.id),
buchungenLaden(betreute.id, { von: jahr.von, bis: jahr.bis }),
vermoegenLaden(betreute.id, jahr.bis),
])
const rechnung = rechnungslegungBerechnen({
von: jahr.von,
bis: jahr.bis,
variante: eingabe.variante,
konten,
buchungen,
betreuungsjahr: jahr,
heute: heute(),
})
const titel =
eingabe.variante === 'UEBERSICHT_1859'
? 'Vermögensübersicht'
: 'Rechnungslegung'
return {
kopf: berichtskopf({
titel: `${titel} – ${jahr.nummer}. Betreuungsjahr`,
betreute,
rechtsgrundlage: eingabe.variante === 'UEBERSICHT_1859' ? '§ 1859 BGB' : '§ 1865 BGB',
erstelltVon: eingabe.erstelltVon,
zeitraumVon: jahr.von,
zeitraumBis: jahr.bis,
}),
betreute,
jahr,
rechnung,
vermoegenEnde,
}
}
/** Der Name, der unter dem Bericht steht. */
export function erstellerName(benutzer: User): string {
return benutzer.name
}
function kurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
+694
View File
@@ -0,0 +1,694 @@
import type {
Aufgabenkreis,
GesundheitsArt,
KontaktArt,
VorgangKategorie,
VorgangStatus,
} from '@prisma/client'
import { formatDatum } from '@/lib/utils'
/**
* Jahresbericht nach § 1863 BGB - Textentwurf.
*
* Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
* zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
* und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
* daraus wird, entscheidet der Betreuer: Der Text ist im Browser
* bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
*
* Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
* dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
* Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
* wurden, tauchen gar nicht auf.
*
* Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
* Daten, damit die Textbausteine testbar bleiben.
*/
// -----------------------------------------------------------------------------
// Eingangsdaten
// -----------------------------------------------------------------------------
export type JahresberichtBetreute = {
anrede: string | null
vorname: string
nachname: string
geburtsdatum: Date | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimEinzugAm: Date | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
pflegegrad: number | null
pflegegradSeit: Date | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
/** Klartext aus diagnosenVerschluesselt. */
diagnosen: string | null
/** Klartext aus ermittelterWilleVerschluesselt. */
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
aufgabenkreise: Aufgabenkreis[]
aufgabenkreiseFreitext: string | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
bestellungBis: Date | null
}
export type JahresberichtKontakt = {
datum: Date
art: KontaktArt
mitBetreuter: boolean
dauerMinuten: number | null
gespraechspartner: string | null
/** Klartext aus wuenscheVerschluesselt. */
wuensche: string | null
}
export type JahresberichtGesundheit = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
titel: string
einrichtung: string | null
abwesenheitstage: number | null
}
export type JahresberichtVorgang = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
naechsterSchritt: string | null
gegenstelle: string | null
genehmigungspflichtig: boolean
erledigtAm: Date | null
}
export type JahresberichtQuellen = {
betreute: JahresberichtBetreute
zeitraumVon: Date
zeitraumBis: Date
kontakte: JahresberichtKontakt[]
gesundheit: JahresberichtGesundheit[]
vorgaenge: JahresberichtVorgang[]
}
export type Berichtsabschnitt = {
nummer: number
titel: string
rechtsgrundlage: string
absaetze: string[]
}
// -----------------------------------------------------------------------------
// Beschriftungen
// -----------------------------------------------------------------------------
/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
export const ERGAENZEN = '[bitte ergänzen]'
export const ENTWURFSHINWEIS =
'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
export const KONTAKTART_EINZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuche',
KRANKENHAUS: 'Krankenhausaufenthalte',
EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
STURZ: 'Stürze',
MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
INHALATION: 'Inhalationsbehandlungen',
HILFSMITTEL: 'Hilfsmittelversorgungen',
IMPFUNG: 'Impfungen',
REHA: 'Rehabilitationsmaßnahmen',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
HEILMITTEL: 'Heilmittelbehandlungen',
SONSTIGES: 'sonstige gesundheitliche Ereignisse',
}
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
IMMOBILIE: 'Immobilie und Wohnraum',
VERSICHERUNG: 'Versicherungen',
HEIM_PFLEGE: 'Heim und Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const AUFGABENKREIS_BESCHRIFTUNG: Record<Aufgabenkreis, string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'weitere Aufgaben',
}
export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
{ nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
{
nummer: 2,
titel: 'Persönlicher Kontakt zur betreuten Person',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
},
{
nummer: 3,
titel: 'Wünsche der betreuten Person und ihre Umsetzung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
},
{
nummer: 4,
titel: 'Erledigte und laufende Angelegenheiten',
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
},
{
nummer: 5,
titel: 'Einschätzung zur Fortführung der Betreuung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
},
]
// -----------------------------------------------------------------------------
// Sprachliche Helfer
// -----------------------------------------------------------------------------
/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
export function aufzaehlung(teile: readonly string[]): string {
const sauber = teile.filter((t) => t.trim() !== '')
if (sauber.length === 0) return ''
if (sauber.length === 1) return sauber[0]!
return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
}
/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
export function malAngabe(anzahl: number): string {
const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
return woerter[anzahl] ?? `${anzahl}-mal`
}
/** 95 Minuten -> "1 Stunde und 35 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
const teile: string[] = []
if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
return aufzaehlung(teile)
}
/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
export function vollerName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
}
/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
export function kurzName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
}
/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
function pronomen(betreute: Pick<JahresberichtBetreute, 'anrede'>): { nominativ: string; possessiv: string } {
const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
}
function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
return zeile.trim() === '' ? null : zeile
}
function tageZwischen(von: Date, bis: Date): number {
return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
}
function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
}
function haeufigkeit<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
return karte
}
// -----------------------------------------------------------------------------
// Die fuenf Abschnitte
// -----------------------------------------------------------------------------
function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const pron = pronomen(betreute)
const absaetze: string[] = []
// --- Aufenthalt ---
const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
if (betreute.heimName) {
const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
`in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
`im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
)
} else {
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
`liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
`Berichtszeitraum gelebt hat.`,
)
}
const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
if (frueher) {
absaetze.push(
`Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
`Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
)
}
// --- Pflege und Behinderung ---
const pflegesaetze: string[] = []
if (betreute.pflegegrad !== null) {
const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
} else {
pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
}
if (betreute.schwerbehinderungGdB !== null) {
const merk = betreute.schwerbehinderungMerkzeichen
? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
: ''
pflegesaetze.push(
`Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
)
}
absaetze.push(pflegesaetze.join(' '))
// --- Gesundheitszustand im Berichtszeitraum ---
const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
const gesundheitssaetze: string[] = []
if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
gesundheitssaetze.push(
`Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
)
}
if (ereignisse.length === 0) {
gesundheitssaetze.push(
`Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
`keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
)
} else {
const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
const aufzaehlungTeile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
gesundheitssaetze.push(
`Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
`${aufzaehlung(aufzaehlungTeile)}.`,
)
const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
if (klinik.length > 0) {
const beschreibungen = klinik.map((e) => {
const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
})
gesundheitssaetze.push(
`Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
)
}
const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
if (abwesend > 0) {
gesundheitssaetze.push(
`Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
)
}
}
absaetze.push(gesundheitssaetze.join(' '))
// --- Vorsorge ---
const vorsorge: string[] = []
vorsorge.push(
betreute.patientenverfuegungVorhanden
? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
: 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
)
vorsorge.push(
betreute.vorsorgevollmachtVorhanden
? 'Eine Vorsorgevollmacht ist vorhanden.'
: 'Eine Vorsorgevollmacht ist nicht vorhanden.',
)
absaetze.push(vorsorge.join(' '))
return absaetze
}
function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
const mitIhr = imFenster
.filter((k) => k.mitBetreuter)
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
absaetze.push(
`Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
`${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
)
if (mitIhr.length === 0) {
absaetze.push(
`Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
`nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
`persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
`nach — ${ERGAENZEN}.`,
)
} else {
const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1
? `ein ${KONTAKTART_EINZAHL[art]}`
: `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
absaetze.push(
`Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
`${aufzaehlung(teile)}.`,
)
const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
const luecken = mitIhr
.slice(1)
.map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
if (groessteLuecke > 0) {
rhythmus.push(
`Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
)
}
rhythmus.push(
`Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
`der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
)
absaetze.push(rhythmus.join(' '))
const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
if (minuten > 0) {
absaetze.push(
`Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
`wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
`das Befinden und die persönliche Lage von ${name}.`,
)
}
}
if (mitDritten.length > 0) {
const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
absaetze.push(
`Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
`${aufzaehlung(teile)}.${wer}`,
)
}
return absaetze
}
function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
absaetze.push(
`Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
`${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
`festgehalten.`,
)
const mitWunsch = quellen.kontakte
.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
.filter((k) => (k.wuensche ?? '').trim() !== '')
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
if (mitWunsch.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
`${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
`Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
)
} else {
absaetze.push(
`Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
)
for (const kontakt of mitWunsch) {
absaetze.push(
`• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
`${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
)
}
absaetze.push(
`Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
`entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
)
}
if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
absaetze.push(
`Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
`${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
)
}
return absaetze
}
function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
const { zeitraumVon, zeitraumBis } = quellen
const absaetze: string[] = []
if (quellen.vorgaenge.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
`hast du bearbeitet?`,
)
return absaetze
}
const erledigt = quellen.vorgaenge.filter(
(v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
absaetze.push(
`Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
`${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
)
const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
)
for (const kategorie of kategorien) {
const fertig = erledigt.filter((v) => v.kategorie === kategorie)
const offen = laufend.filter((v) => v.kategorie === kategorie)
const saetze: string[] = []
if (fertig.length > 0) {
const titel = fertig.map((v) =>
v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
)
saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
}
if (offen.length > 0) {
const titel = offen.map((v) => {
const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
return `${v.titel}${wer}${schritt}`
})
saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
}
absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
}
const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
if (genehmigungspflichtig.length > 0) {
absaetze.push(
`Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
`Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
)
}
return absaetze
}
function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
const { betreute } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
kreise.push(betreute.aufgabenkreiseFreitext.trim())
}
absaetze.push(
kreise.length > 0
? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
: `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
const begruendung =
laufend.length > 0
? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
`${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
`ist daher nicht erkennbar.`
: `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
absaetze.push(
`Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
`fortzuführen. ${begruendung}`,
)
absaetze.push(
`Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
`einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
`${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
`einzuschränken oder aufzuheben.`,
)
absaetze.push(
`${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
`falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
)
return absaetze
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
* gleicher Text - damit sich der Entwurf testen laesst.
*/
export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
const bauer = [
abschnittPersoenlicheVerhaeltnisse,
abschnittKontakt,
abschnittWuensche,
abschnittAngelegenheiten,
abschnittEinschaetzung,
]
return GLIEDERUNG.map((eintrag, i) => ({
nummer: eintrag.nummer,
titel: eintrag.titel,
rechtsgrundlage: eintrag.rechtsgrundlage,
absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
}))
}
/**
* Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
* abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
* damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
*/
export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
return abschnitte
.map((abschnitt) =>
[`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
)
.join('\n\n')
.trim()
}
/** Direktweg von den Quellen zum Text. */
export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
}
/**
* Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
* ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
* nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
* auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
*/
export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
const zeilen = text.replace(/\r\n/g, '\n').split('\n')
const abschnitte: { titel: string | null; absaetze: string[] }[] = []
let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
for (const zeile of zeilen) {
if (/^\s*\d+\.\s+\S/.test(zeile)) {
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
aktuell = { titel: zeile.trim(), absaetze: [] }
continue
}
if (zeile.trim() === '') continue
aktuell.absaetze.push(zeile.trim())
}
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
return abschnitte
}
/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
export function offeneErgaenzungen(text: string): number {
return text.split(ERGAENZEN).length - 1
}
+234
View File
@@ -0,0 +1,234 @@
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.
*
* 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.
*/
// -----------------------------------------------------------------------------
// 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} />)
}
+507
View File
@@ -0,0 +1,507 @@
import 'server-only'
import * as React from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { formatCent, formatDatum } from '@/lib/utils'
import { RICHTUNG_BESCHRIFTUNG, VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { VARIANTE_RECHTSGRUNDLAGE } from '@/lib/finanzen/rechnungslegung'
import type { RechnungslegungDaten } from '@/lib/berichte/gemeinsam'
/**
* Vermoegensuebersicht (§ 1859 BGB) und Rechnungslegung (§ 1865 BGB).
*
* Ein Dokument, zwei Auspraegungen:
*
* - Befreite Betreuer - Ehegatten, Lebenspartner, Eltern, Kinder und
* Geschwister - schulden nach § 1859 BGB nur eine Uebersicht ueber den
* Bestand des Vermoegens. Das ist hier der Regelfall.
* - Alle uebrigen Betreuer legen nach § 1865 BGB vollstaendig Rechnung:
* jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.
*
* Beide Varianten enthalten dieselbe Rechenprobe, und beide weisen eine
* Abweichung offen aus. Der Gedanke dahinter: Ein Bericht, in dem eine
* Differenz stillschweigend verschwindet, ist eine falsche Erklaerung
* gegenueber dem Gericht - eine, die man selbst unterschreibt. Ein
* Bericht, der die Differenz benennt, ist eine ehrliche Zwischenmeldung.
*/
const stile = StyleSheet.create({
abschnitt: { marginTop: 14 },
ueberschrift: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#333333',
},
untertitel: { fontSize: 9.5, fontFamily: 'Helvetica-Bold', marginTop: 8, marginBottom: 3 },
zeile: {
flexDirection: 'row',
paddingVertical: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#dddddd',
},
kopfzeile: {
flexDirection: 'row',
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#999999',
},
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
text: { fontSize: 9 },
klein: { fontSize: 7.5, color: '#555555' },
nummer: { width: 26, fontSize: 8 },
datum: { width: 52, fontSize: 8 },
breit: { flex: 1, paddingRight: 6 },
mittel: { width: 110, paddingRight: 6 },
schmal: { width: 46, paddingRight: 4 },
betrag: { width: 78, textAlign: 'right', fontSize: 9 },
summenzeile: {
flexDirection: 'row',
paddingTop: 4,
marginTop: 2,
borderTopWidth: 1,
borderTopColor: '#333333',
},
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
summenbetrag: { width: 78, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
probe: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#333333',
},
probeStimmt: { borderColor: '#15803d', backgroundColor: '#f0fdf4' },
probeFehlt: { borderColor: '#b91c1c', backgroundColor: '#fef2f2' },
probezeile: { flexDirection: 'row', paddingVertical: 2 },
probetext: { flex: 1, fontSize: 9 },
probebetrag: { width: 90, textAlign: 'right', fontSize: 9 },
hinweisblock: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#b45309',
backgroundColor: '#fffbeb',
},
hinweistext: { fontSize: 8, marginBottom: 3 },
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
fett: { fontFamily: 'Helvetica-Bold' },
})
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
return (
<View style={stile.summenzeile}>
<Text style={stile.summentext}>{text}</Text>
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
</View>
)
}
export function RechnungslegungDokument({
daten,
}: {
daten: RechnungslegungDaten
}): React.ReactElement {
const { rechnung, jahr } = daten
const voll = rechnung.variante === 'VOLL_1865'
const grundlage = VARIANTE_RECHTSGRUNDLAGE[rechnung.variante]
return (
<Document
title={`${daten.kopf.titel} ${daten.betreute.name}`}
author={daten.kopf.erstelltVon}
subject={`${daten.kopf.titel} nach ${grundlage}`}
>
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
<Text style={stile.erklaerung}>
{voll
? 'Vollständige Rechnungslegung nach § 1865 BGB über die Verwaltung des Vermögens der ' +
'betreuten Person. Die Rechnung weist Anfangsbestand, sämtliche Einnahmen und ' +
'Ausgaben sowie den Endbestand aus; die Belege sind fortlaufend nummeriert und ' +
'werden in dieser Reihenfolge beigefügt.'
: 'Vermögensübersicht nach § 1859 BGB. Als befreiter Betreuer ist über den Bestand ' +
'des Vermögens und dessen Veränderung im Betreuungsjahr zu berichten; eine ' +
'vollständige Rechnungslegung mit Einzelbelegen nach § 1865 BGB ist nicht ' +
'geschuldet. Die Belege werden gleichwohl aufbewahrt und können vorgelegt werden.'}
</Text>
<Text style={stile.erklaerung}>
Berichtszeitraum: {jahr.bezeichnung}. Das Betreuungsjahr läuft ab dem Tag der Bestellung
{daten.betreute.bestellungAm
? ` (${formatDatum(daten.betreute.bestellungAm)})`
: ''}
, nicht ab dem Kalenderjahr.
</Text>
{rechnung.zwischenaufstellung && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Zwischenaufstellung</Text>
<Text style={stile.hinweistext}>
Das Betreuungsjahr ist noch nicht abgelaufen. Diese Aufstellung gibt den Stand zum
Zeitpunkt der Erstellung wieder und ersetzt die Rechnungslegung nach Ablauf des
Jahres nicht.
</Text>
</View>
)}
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>A. Bestände</Text>
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.breit]}>Konto</Text>
<Text style={[stile.kopftext, stile.mittel]}>Bank / IBAN</Text>
<Text style={[stile.kopftext, stile.betrag]}>Anfang</Text>
<Text style={[stile.kopftext, stile.betrag]}>Ende</Text>
</View>
{rechnung.bestaende.length === 0 ? (
<Text style={stile.text}>Keine Konten erfasst.</Text>
) : (
rechnung.bestaende.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{konto.bezeichnung}</Text>
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
</View>
<View style={stile.betrag}>
<Text style={stile.text}>{formatCent(konto.anfangCent)}</Text>
<Text style={stile.klein}>{formatDatum(konto.anfangStichtag)}</Text>
</View>
<View style={stile.betrag}>
<Text style={stile.text}>{formatCent(konto.endeCent)}</Text>
<Text style={stile.klein}>{formatDatum(konto.endeStichtag)}</Text>
</View>
</View>
))
)}
<Summenzeile
text={`Anfangsbestand am ${formatDatum(rechnung.von)}`}
betragCent={rechnung.anfangsbestandCent}
/>
<Summenzeile
text={`Endbestand am ${formatDatum(rechnung.bis)}`}
betragCent={rechnung.endbestandCent}
/>
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>B. Einnahmen</Text>
{rechnung.einnahmen.length === 0 ? (
<Text style={stile.text}>Im Berichtszeitraum sind keine Einnahmen erfasst.</Text>
) : (
rechnung.einnahmen.map((gruppe) => (
<View key={gruppe.kategorie} wrap={false}>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
{gruppe.beschriftung}
</Text>
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
{formatCent(gruppe.summeCent)}
</Text>
</View>
{voll &&
gruppe.buchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
{buchung.verwendungszweck && (
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
)}
</View>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
))
)}
<Summenzeile text="Summe Einnahmen" betragCent={rechnung.summeEinnahmenCent} />
{rechnung.vermoegenswirksamEinnahmenCent !== 0n && (
<Text style={stile.klein}>
davon vermögenswirksam (kein laufendes Einkommen):{' '}
{formatCent(rechnung.vermoegenswirksamEinnahmenCent)}
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>C. Ausgaben</Text>
{rechnung.ausgaben.length === 0 ? (
<Text style={stile.text}>Im Berichtszeitraum sind keine Ausgaben erfasst.</Text>
) : (
rechnung.ausgaben.map((gruppe) => (
<View key={gruppe.kategorie} wrap={false}>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
{gruppe.beschriftung}
</Text>
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
{formatCent(gruppe.summeCent)}
</Text>
</View>
{voll &&
gruppe.buchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
{buchung.verwendungszweck && (
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
)}
</View>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
))
)}
<Summenzeile text="Summe Ausgaben" betragCent={rechnung.summeAusgabenCent} />
{rechnung.vermoegenswirksamAusgabenCent !== 0n && (
<Text style={stile.klein}>
davon vermögenswirksam: {formatCent(rechnung.vermoegenswirksamAusgabenCent)}
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
<View
style={[stile.probe, rechnung.probe.stimmt ? stile.probeStimmt : stile.probeFehlt]}
wrap={false}
>
<Text style={[stile.probetext, stile.fett, { marginBottom: 4 }]}>D. Rechenprobe</Text>
<View style={stile.probezeile}>
<Text style={stile.probetext}>Anfangsbestand</Text>
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.anfangsbestandCent)}</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>zuzüglich Einnahmen</Text>
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.einnahmenCent)}</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>abzüglich Ausgaben</Text>
<Text style={stile.probebetrag}>{formatCent(-rechnung.probe.ausgabenCent)}</Text>
</View>
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
<Text style={[stile.probetext, stile.fett]}>rechnerischer Endbestand</Text>
<Text style={[stile.probebetrag, stile.fett]}>
{formatCent(rechnung.probe.rechnerischerEndbestandCent)}
</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>Endbestand laut Saldoständen</Text>
<Text style={stile.probebetrag}>
{formatCent(rechnung.probe.gebuchterEndbestandCent)}
</Text>
</View>
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
<Text style={[stile.probetext, stile.fett]}>
{rechnung.probe.stimmt ? 'Differenz – die Rechnung geht auf' : 'Differenz'}
</Text>
<Text style={[stile.probebetrag, stile.fett]}>
{formatCent(rechnung.probe.differenzCent)}
</Text>
</View>
{!rechnung.probe.stimmt && (
<Text style={[stile.hinweistext, { marginTop: 5 }]}>
Die Rechenprobe geht nicht auf. Die Differenz ist hier ausgewiesen und nicht
ausgeglichen worden. Sie bedeutet, dass entweder Buchungen im Kassenbuch fehlen oder
ein Saldostand nicht erfasst ist. Vor der Abgabe an das Gericht ist die Ursache zu
klären.
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
{rechnung.umbuchungen.length > 0 && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>E. Umbuchungen zwischen eigenen Konten</Text>
<Text style={stile.erklaerung}>
Diese Vorgänge verschieben Geld zwischen Konten der betreuten Person. Sie sind weder
Einnahme noch Ausgabe und daher in keiner Summe oben enthalten; sie stehen hier nur
der Vollständigkeit halber.
</Text>
{rechnung.umbuchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<Text style={[stile.text, stile.breit]}>
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Umbuchung'}
</Text>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
)}
{rechnung.eigeneAuslagen.length > 0 && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>F. Eigene Auslagen des Betreuers</Text>
<Text style={stile.erklaerung}>
Aufwendungen, die der Betreuer aus eigenen Mitteln getragen hat. Sie gehören nicht
zum Vermögen der betreuten Person und sind deshalb in den Summen oben nicht
enthalten. Ihre Erstattung richtet sich nach §§ 1877, 1878 BGB.
</Text>
{rechnung.eigeneAuslagen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<Text style={[stile.text, stile.breit]}>
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Auslage'}
</Text>
<Text style={[stile.klein, stile.schmal]}>
{RICHTUNG_BESCHRIFTUNG[buchung.richtung]}
</Text>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
<Summenzeile text="Summe eigene Auslagen" betragCent={rechnung.eigeneAuslagenCent} />
</View>
)}
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt} break={voll}>
<Text style={stile.ueberschrift}>
{voll ? 'G. Vermögensbestand am Ende des Zeitraums' : 'E. Vermögensbestand am Ende des Zeitraums'}
</Text>
{daten.vermoegenEnde.aktivaGruppen.length === 0 &&
daten.vermoegenEnde.passiva.length === 0 ? (
<Text style={stile.text}>Keine Vermögensposten erfasst.</Text>
) : (
<>
{daten.vermoegenEnde.aktivaGruppen.map((gruppe) => (
<View key={gruppe.art} wrap={false}>
<Text style={stile.untertitel}>
{VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
</Text>
{gruppe.posten.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
<Text style={[stile.klein, stile.schmal]}>
{formatDatum(posten.bewertetAm)}
</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
</View>
))}
{daten.vermoegenEnde.passiva.length > 0 && (
<>
<Text style={stile.untertitel}>Verbindlichkeiten</Text>
{daten.vermoegenEnde.passiva.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
<Text style={[stile.klein, stile.schmal]}>
{formatDatum(posten.bewertetAm)}
</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
</>
)}
<Summenzeile
text="Reinvermögen aus Vermögensposten"
betragCent={daten.vermoegenEnde.reinvermoegenCent}
/>
<Text style={stile.klein}>
Kontoguthaben sind hier nicht enthalten – sie stehen unter A. Bestände.
</Text>
</>
)}
</View>
{/* ------------------------------------------------------------- */}
{voll && (
<View style={stile.abschnitt} break>
<Text style={stile.ueberschrift}>H. Belegverzeichnis</Text>
<Text style={stile.erklaerung}>
Laufende Nummerierung nach Datum. Die Belege sind in dieser Reihenfolge abgeheftet.
</Text>
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.nummer]}>Nr.</Text>
<Text style={[stile.kopftext, stile.datum]}>Datum</Text>
<Text style={[stile.kopftext, stile.breit]}>Vorgang</Text>
<Text style={[stile.kopftext, stile.mittel]}>Beleg</Text>
<Text style={[stile.kopftext, stile.betrag]}>Betrag</Text>
</View>
{rechnung.belege.map((beleg) => (
<View key={beleg.buchungId} style={stile.zeile}>
<Text style={stile.nummer}>{beleg.nummer}</Text>
<Text style={stile.datum}>{formatDatum(beleg.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{beleg.beschriftung}</Text>
{beleg.verwendungszweck && beleg.gegenstelle && (
<Text style={stile.klein}>{beleg.verwendungszweck}</Text>
)}
</View>
<View style={stile.mittel}>
{beleg.belegTitel ? (
<Text style={stile.klein}>{beleg.belegTitel}</Text>
) : beleg.belegFehltBegruendung ? (
<Text style={stile.klein}>
ohne Beleg – {beleg.belegFehltBegruendung}
</Text>
) : (
<Text style={[stile.klein, stile.fett]}>Beleg fehlt</Text>
)}
</View>
<Text style={stile.betrag}>
{beleg.richtung === 'AUSGABE'
? formatCent(-beleg.betragCent)
: formatCent(beleg.betragCent)}
</Text>
</View>
))}
</View>
)}
{!voll && rechnung.beleg.ohneNachweis > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Belege</Text>
<Text style={stile.hinweistext}>
Von {rechnung.beleg.gesamt} Buchungen des Berichtszeitraums sind{' '}
{rechnung.beleg.mitBeleg} mit einem Beleg hinterlegt, {rechnung.beleg.begruendet}{' '}
tragen eine Begründung für den fehlenden Beleg, und{' '}
{rechnung.beleg.ohneNachweis} haben beides nicht.
</Text>
</View>
)}
{rechnung.hinweise.length > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Anmerkungen zur Aufstellung</Text>
{rechnung.hinweise.map((hinweis, i) => (
<Text key={i} style={stile.hinweistext}>
• {hinweis}
</Text>
))}
</View>
)}
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
Ich versichere, die Aufstellung nach bestem Wissen und Gewissen und auf Grundlage der
vorliegenden Kontoauszüge und Belege erstellt zu haben.
</Text>
</Berichtsseite>
</Document>
)
}
export async function rechnungslegungPdf(daten: RechnungslegungDaten): Promise<Buffer> {
return pdfErzeugen(<RechnungslegungDokument daten={daten} />)
}
export function rechnungslegungDateiname(daten: RechnungslegungDaten): string {
const bis = daten.jahr.bis.toISOString().slice(0, 10)
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
const art =
daten.rechnung.variante === 'UEBERSICHT_1859' ? 'Vermoegensuebersicht' : 'Rechnungslegung'
return `${bis}-${art}-Betreuungsjahr-${daten.jahr.nummer}-${name}.pdf`
}
+441
View File
@@ -0,0 +1,441 @@
import 'server-only'
import { createElement as e, type ReactElement, type ReactNode } from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
import { paginieren, pdfErzeugen, pdfsZusammenfuehren } from '@/lib/pdf/erzeugen'
import { lesen } from '@/lib/speicher'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { berichtDateiname } from '@/lib/validierung/berichte'
/**
* Unterlagenpaket fuer den Sozialhilfetraeger.
*
* Der Bezirk fordert Unterlagen nach - meist zehn bis zwanzig Positionen,
* verteilt ueber mehrere Schreiben. Wer darauf mit einem Stapel loser
* Kopien antwortet, bekommt Wochen spaeter die Nachfrage, welche Unterlage
* denn nun zu welcher Position gehoere.
*
* Deshalb entsteht hier EIN PDF: Anschreiben mit laufender Nummerierung,
* Inhaltsverzeichnis, dann die Unterlagen in genau dieser Reihenfolge,
* durchpaginiert. Auch das, was es nicht gibt, steht drin - mit Begruendung.
* Eine nicht beantwortete Position gilt beim Traeger sonst als fehlende
* Mitwirkung, und das kostet Leistung.
*
* Ohne JSX, weil die Datei nach dem Bauplan .ts heisst.
*/
/**
* Anschreiben und Inhaltsverzeichnis liegen vor den Anlagen. Beide sind auf
* je eine Seite hin gestaltet; wird eines laenger, verschiebt sich die
* Angabe im Verzeichnis um denselben Betrag nach hinten. Das nehmen wir in
* Kauf - die laufende Nummer bleibt in jedem Fall richtig, und danach
* sucht der Sachbearbeiter.
*/
const VORSPANN_SEITEN = 2
const stile = StyleSheet.create({
absatz: { fontSize: 10, lineHeight: 1.5, marginBottom: 8, textAlign: 'justify' },
betreff: { fontSize: 11, fontFamily: 'Helvetica-Bold', marginBottom: 10 },
ueberschrift: { fontSize: 12, fontFamily: 'Helvetica-Bold', marginTop: 16, marginBottom: 6 },
anschrift: { fontSize: 10, lineHeight: 1.4, marginBottom: 18 },
zeile: { flexDirection: 'row', marginBottom: 4 },
nummer: { fontSize: 10, width: 28 },
bezeichnung: { fontSize: 10, flexGrow: 1, paddingRight: 8 },
bemerkung: { fontSize: 9, color: '#555555', width: 150, textAlign: 'right' },
fehlend: { fontSize: 10, lineHeight: 1.45, marginBottom: 6, marginLeft: 28 },
})
// -----------------------------------------------------------------------------
// Aufstellung
// -----------------------------------------------------------------------------
export type Unterlage = {
id: string
bezeichnung: string
erlaeuterung: string | null
status: string
begruendungNichtVorhanden: string | null
reihenfolge: number
/** Angehaengte Dokumente in der Reihenfolge, in der sie eingescannt wurden. */
dokumente: {
id: string
titel: string
schreibenVom: Date | null
objektSchluessel: string | null
mimeTyp: string | null
seitenzahl: number | null
}[]
}
export type Aufstellung = {
/** Position im Anschreiben - beginnt bei 1 und zaehlt lueckenlos hoch. */
nummer: number
unterlage: Unterlage
beigefuegt: boolean
}
/**
* Nummeriert die ausgewaehlten Positionen. Reine Funktion, damit die
* laufende Nummer im Anschreiben, im Inhaltsverzeichnis und in der
* Reihenfolge der angehaengten Seiten garantiert dieselbe ist.
*/
export function unterlagenAufstellen(unterlagen: Unterlage[]): Aufstellung[] {
return [...unterlagen]
.sort((a, b) => a.reihenfolge - b.reihenfolge || a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
.map((unterlage, i) => ({
nummer: i + 1,
unterlage,
beigefuegt: unterlage.dokumente.some((d) => d.objektSchluessel !== null),
}))
}
// -----------------------------------------------------------------------------
// Anschreiben und Inhaltsverzeichnis
// -----------------------------------------------------------------------------
export type Anschreibendaten = {
traeger: string
leistungsart: string
aktenzeichen: string | null
sachbearbeiter: string | null
betreuteName: string
betreuteGeburtsdatum: Date | null
erstelltVon: string
erstelltAm: Date
/** Freitext, den der Betreuer im Formular ergaenzt hat. */
zusatz: string
}
function anschriftBlock(daten: Anschreibendaten) {
const zeilen = [daten.traeger]
if (daten.sachbearbeiter) zeilen.push(`z. Hd. ${daten.sachbearbeiter}`)
return e(
View,
{ style: stile.anschrift },
...zeilen.map((zeileText, i) => e(Text, { key: `an-${i}` }, zeileText)),
)
}
function positionsZeile(eintrag: Aufstellung) {
const bemerkung = eintrag.beigefuegt
? `Anlage ${eintrag.nummer}`
: 'nicht vorhanden – siehe Erklärung'
return e(
View,
{ key: `pos-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
e(Text, { style: stile.bemerkung }, bemerkung),
)
}
/** Das Anschreiben als eigenstaendiges react-pdf-Dokument. */
export function AnschreibenDokument(props: { kopf: Berichtskopf; daten: Anschreibendaten; aufstellung: Aufstellung[] }) {
const { kopf, daten, aufstellung } = props
const beigefuegt = aufstellung.filter((a) => a.beigefuegt)
const fehlend = aufstellung.filter((a) => !a.beigefuegt)
const kinder: ReactNode[] = [
anschriftBlock(daten),
e(
Text,
{ key: 'betreff', style: stile.betreff },
`Betreff: ${daten.leistungsart} für ${daten.betreuteName}` +
(daten.aktenzeichen ? ` – Aktenzeichen ${daten.aktenzeichen}` : ''),
),
e(
Text,
{ key: 'anrede', style: stile.absatz },
daten.sachbearbeiter ? `Sehr geehrte Damen und Herren,` : 'Sehr geehrte Damen und Herren,',
),
e(
Text,
{ key: 'einleitung', style: stile.absatz },
`als rechtlicher Betreuer von ${daten.betreuteName}` +
(daten.betreuteGeburtsdatum ? `, geboren am ${formatDatum(daten.betreuteGeburtsdatum)}` : '') +
`, übersende ich Ihnen die von Ihnen angeforderten Unterlagen. ` +
`Die Unterlagen sind fortlaufend nummeriert und in dieser Reihenfolge diesem Schreiben ` +
`beigefügt; das Inhaltsverzeichnis auf der Folgeseite nennt die zugehörigen Seiten.`,
),
e(Text, { key: 'ueberschrift-liste', style: stile.ueberschrift }, 'Beigefügte Unterlagen'),
]
if (beigefuegt.length > 0) {
kinder.push(e(View, { key: 'liste' }, ...beigefuegt.map(positionsZeile)))
} else {
kinder.push(
e(
Text,
{ key: 'liste-leer', style: stile.absatz },
'Diesem Schreiben liegen keine Unterlagen bei; zu den angeforderten Positionen finden Sie ' +
'unten die jeweilige Erklärung.',
),
)
}
if (fehlend.length > 0) {
kinder.push(
e(
Text,
{ key: 'ueberschrift-fehlend', style: stile.ueberschrift },
'Nicht vorhandene Unterlagen',
),
e(
Text,
{ key: 'fehlend-einleitung', style: stile.absatz },
'Zu den folgenden Positionen kann ich keine Unterlage vorlegen. Ich erkläre dazu:',
),
...fehlend.map((eintrag) =>
e(
Text,
{ key: `fehlend-${eintrag.unterlage.id}`, style: stile.fehlend },
`${eintrag.nummer}. ${eintrag.unterlage.bezeichnung} — ` +
(eintrag.unterlage.begruendungNichtVorhanden?.trim() ||
'Diese Unterlage liegt mir nicht vor. Ich bemühe mich weiter darum und reiche sie nach, sobald sie mir vorliegt.'),
),
),
)
}
if (daten.zusatz.trim() !== '') {
kinder.push(
e(Text, { key: 'zusatz-ueberschrift', style: stile.ueberschrift }, 'Ergänzende Hinweise'),
e(Text, { key: 'zusatz', style: stile.absatz }, daten.zusatz.trim()),
)
}
kinder.push(
e(
Text,
{ key: 'schluss', style: stile.absatz },
'Für Rückfragen stehe ich Ihnen gerne zur Verfügung. Ich bitte um eine kurze ' +
'Eingangsbestätigung.',
),
e(Text, { key: 'gruss', style: stile.absatz }, 'Mit freundlichen Grüßen'),
)
return e(
Document,
{ title: kopf.titel, author: daten.erstelltVon, language: 'de' },
e(Berichtsseite, { kopf, mitUnterschrift: true }, ...kinder),
)
}
/** Das Inhaltsverzeichnis mit den Seitenzahlen der Anlagen. */
export function InhaltsverzeichnisDokument(props: {
kopf: Berichtskopf
aufstellung: Aufstellung[]
/** Erste Seite der jeweiligen Anlage im zusammengefuehrten PDF. */
startseiten: Map<string, number>
}) {
const { kopf, aufstellung, startseiten } = props
return e(
Document,
{ title: `${kopf.titel} – Inhaltsverzeichnis`, author: kopf.erstelltVon, language: 'de' },
e(
Berichtsseite,
{ kopf: { ...kopf, titel: `${kopf.titel} – Inhaltsverzeichnis` }, mitUnterschrift: false },
e(
Text,
{ key: 'einleitung', style: stile.absatz },
'Die Seitenzahlen beziehen sich auf die durchlaufende Nummerierung am Fuß dieses ' +
'Dokuments.',
),
...aufstellung.map((eintrag) => {
const seite = startseiten.get(eintrag.unterlage.id)
const bemerkung = eintrag.beigefuegt
? seite !== undefined
? `ab Seite ${seite}`
: 'beigefügt'
: 'nicht vorhanden'
return e(
View,
{ key: `inh-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
e(Text, { style: stile.bemerkung }, bemerkung),
)
}),
),
)
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
export type UnterlagenpaketErgebnis = {
pdf: Buffer
dateiname: string
aufstellung: Aufstellung[]
/** Unterlagen, deren Datei nicht gelesen werden konnte. */
uebersprungen: { bezeichnung: string; grund: string }[]
}
/**
* Erzeugt das komplette Paket. Der Aufrufer hat den Zugriff auf die
* betreute Person bereits geprueft.
*/
export async function unterlagenpaketErzeugen(optionen: {
betreuteId: string
antragId: string
nachforderungIds: string[]
anschreibenZusatz: string
erstelltVon: string
erstelltAm?: Date
}): Promise<UnterlagenpaketErgebnis> {
const erstelltAm = optionen.erstelltAm ?? new Date()
const [betreute, antrag, nachforderungen] = await Promise.all([
db.betreute.findFirstOrThrow({
where: { id: optionen.betreuteId, ...nichtGeloescht },
select: { vorname: true, nachname: true, anrede: true, geburtsdatum: true },
}),
db.sozialhilfeAntrag.findFirstOrThrow({
where: { id: optionen.antragId, betreuteId: optionen.betreuteId, ...nichtGeloescht },
select: {
traeger: true,
leistungsart: true,
aktenzeichen: true,
sachbearbeiter: true,
},
}),
db.nachforderung.findMany({
where: {
id: { in: optionen.nachforderungIds },
antragId: optionen.antragId,
antrag: { betreuteId: optionen.betreuteId },
...nichtGeloescht,
},
include: {
verknuepfungen: {
where: { betreuteId: optionen.betreuteId },
include: {
dokument: {
select: {
id: true,
titel: true,
schreibenVom: true,
objektSchluessel: true,
mimeTyp: true,
seitenzahl: true,
geloeschtAm: true,
},
},
},
},
},
}),
])
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
.filter(Boolean)
.join(' ')
const unterlagen: Unterlage[] = nachforderungen.map((n) => ({
id: n.id,
bezeichnung: n.bezeichnung,
erlaeuterung: n.erlaeuterung,
status: n.status,
begruendungNichtVorhanden: n.begruendungNichtVorhanden,
reihenfolge: n.reihenfolge,
dokumente: n.verknuepfungen
.map((v) => v.dokument)
.filter((d): d is NonNullable<typeof d> => d !== null && d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
schreibenVom: d.schreibenVom,
objektSchluessel: d.objektSchluessel,
mimeTyp: d.mimeTyp,
seitenzahl: d.seitenzahl,
})),
}))
const aufstellung = unterlagenAufstellen(unterlagen)
const kopf: Berichtskopf = {
titel: 'Unterlagen zur Hilfe zur Pflege',
betreute: { name: betreuteName, geburtsdatum: betreute.geburtsdatum },
behoerde: antrag.traeger,
aktenzeichen: antrag.aktenzeichen ?? undefined,
rechtsgrundlage: '§ 60 SGB I (Mitwirkungspflicht)',
erstelltAm,
erstelltVon: optionen.erstelltVon,
}
const daten: Anschreibendaten = {
traeger: antrag.traeger,
leistungsart: antrag.leistungsart,
aktenzeichen: antrag.aktenzeichen,
sachbearbeiter: antrag.sachbearbeiter,
betreuteName,
betreuteGeburtsdatum: betreute.geburtsdatum,
erstelltVon: optionen.erstelltVon,
erstelltAm,
zusatz: optionen.anschreibenZusatz,
}
// 1. Anlagen einlesen und dabei merken, auf welcher Seite jede beginnt.
const uebersprungen: { bezeichnung: string; grund: string }[] = []
const anlagen: Buffer[] = []
const startseiten = new Map<string, number>()
// Anschreiben und Inhaltsverzeichnis stehen vorne; ihre Seitenzahl steht
// erst nach dem Rendern fest, deshalb zunaechst als Versatz gefuehrt.
let seitenversatz = 0
for (const eintrag of aufstellung) {
if (!eintrag.beigefuegt) continue
for (const dokument of eintrag.unterlage.dokumente) {
if (!dokument.objektSchluessel) continue
try {
const inhalt = await lesen(dokument.objektSchluessel)
if (!startseiten.has(eintrag.unterlage.id)) {
startseiten.set(eintrag.unterlage.id, seitenversatz + 1)
}
anlagen.push(inhalt)
seitenversatz += dokument.seitenzahl ?? 1
} catch (fehler) {
uebersprungen.push({
bezeichnung: `${eintrag.unterlage.bezeichnung} – ${dokument.titel}`,
grund: (fehler as Error).message,
})
}
}
if (!startseiten.has(eintrag.unterlage.id)) {
// Alle Dateien dieser Position waren unlesbar.
eintrag.beigefuegt = false
}
}
const anschreiben = await pdfErzeugen(
AnschreibenDokument({ kopf, daten, aufstellung }) as ReactElement,
)
const verzeichnisSeiten = new Map(
[...startseiten].map(([id, seite]) => [id, seite + VORSPANN_SEITEN]),
)
const inhaltsverzeichnis = await pdfErzeugen(
InhaltsverzeichnisDokument({
kopf,
aufstellung,
startseiten: verzeichnisSeiten,
}) as ReactElement,
)
const zusammengefuehrt = await pdfsZusammenfuehren([anschreiben, inhaltsverzeichnis, ...anlagen])
const pdf = await paginieren(
zusammengefuehrt,
`${betreuteName}${antrag.aktenzeichen ? ` · ${antrag.aktenzeichen}` : ''} · ` +
`Unterlagen vom ${formatDatum(erstelltAm)}`,
)
return {
pdf,
dateiname: berichtDateiname('unterlagen-bezirk', betreute.nachname, erstelltAm, 'pdf'),
aufstellung,
uebersprungen,
}
}
+376
View File
@@ -0,0 +1,376 @@
import 'server-only'
import * as React from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { formatCent, formatDatum } from '@/lib/utils'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import type { VermoegensverzeichnisDaten } from '@/lib/berichte/gemeinsam'
/**
* Vermoegensverzeichnis nach § 1835 BGB.
*
* Binnen drei Monaten nach der Bestellung beim Betreuungsgericht
* einzureichen. Es beantwortet eine einzige Frage vollstaendig: Was
* gehoert der betreuten Person am Stichtag, und was schuldet sie?
*
* Drei Dinge machen dieses Verzeichnis brauchbar - und alle drei sind
* hier eingebaut:
*
* 1. Jede Zahl traegt ihre Herkunft. Neben jedem Wert steht, woher er
* stammt und von wann er ist. Ein Betrag ohne Quelle ist beim Gericht
* wertlos.
* 2. Luecken werden benannt, nicht mit Nullen gefuellt. Ein Posten ohne
* Bewertung steht ausdruecklich als "noch nicht bewertet" drin.
* 3. Neben dem Bestand stehen die laufenden Einnahmen und Ausgaben.
* Danach fragt das Gericht als Naechstes, und danach fragt der Bezirk
* im Verfahren zur Hilfe zur Pflege.
*/
const stile = StyleSheet.create({
abschnitt: { marginTop: 14 },
ueberschrift: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#333333',
},
untertitel: {
fontSize: 9.5,
fontFamily: 'Helvetica-Bold',
marginTop: 8,
marginBottom: 3,
},
zeile: {
flexDirection: 'row',
paddingVertical: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#dddddd',
},
kopfzeile: {
flexDirection: 'row',
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#999999',
},
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
text: { fontSize: 9 },
klein: { fontSize: 7.5, color: '#555555' },
breit: { flex: 1, paddingRight: 6 },
mittel: { width: 120, paddingRight: 6 },
schmal: { width: 62, paddingRight: 6 },
betrag: { width: 82, textAlign: 'right', fontSize: 9 },
summenzeile: {
flexDirection: 'row',
paddingTop: 4,
marginTop: 2,
borderTopWidth: 1,
borderTopColor: '#333333',
},
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
summenbetrag: { width: 82, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
hinweisblock: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#b45309',
backgroundColor: '#fffbeb',
},
hinweistext: { fontSize: 8, marginBottom: 3 },
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
})
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
return (
<View style={stile.summenzeile}>
<Text style={stile.summentext}>{text}</Text>
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
</View>
)
}
function Tabellenkopf({ spalten }: { spalten: [string, string, string, string] }) {
return (
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.breit]}>{spalten[0]}</Text>
<Text style={[stile.kopftext, stile.mittel]}>{spalten[1]}</Text>
<Text style={[stile.kopftext, stile.schmal]}>{spalten[2]}</Text>
<Text style={[stile.kopftext, stile.betrag]}>{spalten[3]}</Text>
</View>
)
}
export function VermoegensverzeichnisDokument({
daten,
}: {
daten: VermoegensverzeichnisDaten
}): React.ReactElement {
const kontenOhneBetrag = daten.kontostaende.filter((k) => k.betragCent === null)
return (
<Document
title={`Vermögensverzeichnis ${daten.betreute.name}`}
author={daten.kopf.erstelltVon}
subject={`Vermögensverzeichnis nach § 1835 BGB zum ${formatDatum(daten.stichtag)}`}
>
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
<Text style={stile.erklaerung}>
Verzeichnis des Vermögens der betreuten Person zum Stichtag {formatDatum(daten.stichtag)},
aufgestellt nach § 1835 BGB. Jeder Wert ist mit seiner Herkunft und dem Tag seiner
Bewertung angegeben. Beträge, für die zum Stichtag keine belastbare Bewertung vorliegt,
sind am Ende des Verzeichnisses gesondert aufgeführt und nicht in die Summen eingerechnet.
</Text>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>A. Aktiva</Text>
<Text style={stile.untertitel}>I. Konten und Guthaben</Text>
{daten.kontostaende.length === 0 ? (
<Text style={stile.text}>Keine Konten erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Konto', 'Bank / IBAN', 'Stand vom', 'Betrag']} />
{daten.kontostaende.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{konto.bezeichnung}</Text>
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
</View>
<View style={stile.schmal}>
<Text style={stile.klein}>{formatDatum(konto.stichtag)}</Text>
{konto.quelle && <Text style={stile.klein}>{konto.quelle}</Text>}
</View>
<Text style={stile.betrag}>
{konto.betragCent === null ? 'nicht erfasst' : formatCent(konto.betragCent)}
</Text>
</View>
))}
<Summenzeile text="Summe Konten und Guthaben" betragCent={daten.summeKontenCent} />
</>
)}
{daten.vermoegen.aktivaGruppen.map((gruppe, i) => (
<View key={gruppe.art} wrap={false}>
<Text style={stile.untertitel}>
{romanisch(i + 2)}. {VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
</Text>
<Tabellenkopf spalten={['Posten', 'Wertquelle / Nachweis', 'Bewertet', 'Wert']} />
{gruppe.posten.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{posten.bezeichnung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{posten.wertquelle}</Text>
{posten.nachweisTitel && (
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
)}
</View>
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
<Summenzeile
text={`Summe ${VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}`}
betragCent={gruppe.summeCent}
/>
</View>
))}
<Summenzeile text="Summe Aktiva" betragCent={daten.summeAktivaCent} />
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>B. Passiva (Verbindlichkeiten)</Text>
{daten.vermoegen.passiva.length === 0 ? (
<Text style={stile.text}>Keine Verbindlichkeiten erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Verbindlichkeit', 'Quelle / Nachweis', 'Bewertet', 'Betrag']} />
{daten.vermoegen.passiva.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<View style={stile.mittel}>
<Text style={stile.klein}>{posten.wertquelle}</Text>
{posten.nachweisTitel && (
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
)}
</View>
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
<Summenzeile text="Summe Passiva" betragCent={daten.summePassivaCent} />
</>
)}
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>C. Reinvermögen zum Stichtag</Text>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>Aktiva</Text>
<Text style={stile.betrag}>{formatCent(daten.summeAktivaCent)}</Text>
</View>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>abzüglich Passiva</Text>
<Text style={stile.betrag}>{formatCent(-daten.summePassivaCent)}</Text>
</View>
<Summenzeile text="Reinvermögen" betragCent={daten.reinvermoegenCent} />
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt} break>
<Text style={stile.ueberschrift}>
D. Laufende Einnahmen und Ausgaben ({daten.laufend.monate} Monate bis{' '}
{formatDatum(daten.laufend.bis)})
</Text>
<Text style={stile.erklaerung}>
Grundlage sind die im Kassenbuch erfassten Buchungen des genannten Zeitraums, auf den
Monat heruntergerechnet. Einmalige vermögenswirksame Vorgänge – etwa ein Verkaufserlös
oder ein Rückkaufswert – sind hier bewusst nicht enthalten; sie sind keine laufenden
Einnahmen und stehen im Bestandsteil dieses Verzeichnisses.
</Text>
<Text style={stile.untertitel}>Einnahmen</Text>
{daten.laufend.einnahmen.length === 0 ? (
<Text style={stile.text}>Keine laufenden Einnahmen erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Art der Einnahme', '', 'Vorgänge', 'je Monat']} />
{daten.laufend.einnahmen.map((posten) => (
<View key={posten.kategorie} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Zeitraum gesamt: {formatCent(posten.summeCent)}
</Text>
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
</View>
))}
<Summenzeile
text="Summe laufende Einnahmen je Monat"
betragCent={daten.laufend.monatlichEinnahmenCent}
/>
</>
)}
<Text style={stile.untertitel}>Ausgaben</Text>
{daten.laufend.ausgaben.length === 0 ? (
<Text style={stile.text}>Keine laufenden Ausgaben erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Art der Ausgabe', '', 'Vorgänge', 'je Monat']} />
{daten.laufend.ausgaben.map((posten) => (
<View key={posten.kategorie} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Zeitraum gesamt: {formatCent(posten.summeCent)}
</Text>
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
</View>
))}
<Summenzeile
text="Summe laufende Ausgaben je Monat"
betragCent={daten.laufend.monatlichAusgabenCent}
/>
</>
)}
<Summenzeile
text="Rechnerischer Überschuss / Fehlbetrag je Monat"
betragCent={
daten.laufend.monatlichEinnahmenCent - daten.laufend.monatlichAusgabenCent
}
/>
</View>
{/* ------------------------------------------------------------- */}
{(daten.vermoegen.ohneWert.length > 0 || kontenOhneBetrag.length > 0) && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>E. Noch nicht bewertete Posten</Text>
<Text style={stile.erklaerung}>
Diese Posten sind vorhanden, konnten zum Stichtag aber nicht belastbar bewertet
werden. Sie sind in keiner Summe dieses Verzeichnisses enthalten.
</Text>
{kontenOhneBetrag.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{konto.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Kein Saldostand zum Stichtag erfasst.
</Text>
</View>
))}
{daten.vermoegen.ohneWert.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>
{VERMOEGENS_ART_BESCHRIFTUNG[posten.art]} – keine Bewertung zum Stichtag.
</Text>
</View>
))}
</View>
)}
{daten.hinweise.length > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, { fontFamily: 'Helvetica-Bold' }]}>
Anmerkungen zur Aufstellung
</Text>
{daten.hinweise.map((hinweis, i) => (
<Text key={i} style={stile.hinweistext}>
• {hinweis}
</Text>
))}
</View>
)}
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
Ich versichere, das Verzeichnis nach bestem Wissen und Gewissen aufgestellt zu haben.
Die angegebenen Werte beruhen auf den jeweils genannten Quellen; die zugehörigen
Nachweise können auf Anforderung vorgelegt werden.
</Text>
</Berichtsseite>
</Document>
)
}
/** I, II, III … fuer die Gliederung der Aktiva. */
function romanisch(zahl: number): string {
const zeichen: [number, string][] = [
[10, 'X'],
[9, 'IX'],
[5, 'V'],
[4, 'IV'],
[1, 'I'],
]
let rest = zahl
let ergebnis = ''
for (const [wert, text] of zeichen) {
while (rest >= wert) {
ergebnis += text
rest -= wert
}
}
return ergebnis
}
export async function vermoegensverzeichnisPdf(daten: VermoegensverzeichnisDaten): Promise<Buffer> {
return pdfErzeugen(<VermoegensverzeichnisDokument daten={daten} />)
}
/** Dateiname fuer den Download - Datum voran, damit die Ablage sortiert. */
export function vermoegensverzeichnisDateiname(daten: VermoegensverzeichnisDaten): string {
const stichtag = daten.stichtag.toISOString().slice(0, 10)
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
return `${stichtag}-Vermoegensverzeichnis-${name}.pdf`
}
+531
View File
@@ -0,0 +1,531 @@
import 'server-only'
import { createHash } from 'node:crypto'
import type { Readable } from 'node:stream'
import archiver from 'archiver'
import { db, nichtGeloescht } from '@/lib/db'
import { lesen } from '@/lib/speicher'
import { oeffnen, VERSCHLUESSELTE_FELDER, type VerschluesseltesModell } from '@/lib/crypto/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
import {
berichtDateiname,
exportDokumentPfad,
pfadErfordern,
sichererDateiname,
} from '@/lib/validierung/berichte'
/**
* Vollexport - alles, was ueber eine betreute Person gespeichert ist, in
* einem ZIP.
*
* Das ist die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine
* Daten gehoeren dir, und du kommst jederzeit ohne mich an sie heran. Der
* Export enthaelt die Originaldateien, die Datenbankinhalte im Klartext als
* JSON, die erzeugten Berichte, eine Anleitung und Pruefsummen. Wer die
* Anwendung morgen abschaltet, kann mit diesem Archiv weiterarbeiten.
*
* Erzeugt wird als Strom. Ein Bestand aus mehreren hundert eingescannten
* Bescheiden waere als Buffer im Speicher ein Problem; hier wandert jede
* Datei einzeln durch: lesen, Pruefsumme bilden, ins Archiv schieben,
* vergessen. Deshalb wird nach jedem Eintrag auf das Ereignis "entry"
* gewartet - das ist der Gegendruck, ohne den der Archivierer schneller
* annimmt, als der Browser abnimmt.
*
* Der Export ist eine sensible Aktion: frische Anmeldung, Rolle BETREUER
* oder ADMIN, Audit-Eintrag EXPORTIERT. Das prueft die Server Action, nicht
* diese Datei.
*/
export type VollexportOptionen = {
betreuteId: string
mitDokumenten: boolean
mitAuditLog: boolean
mitBerichten: boolean
/** Name des Benutzers, der exportiert - steht in der LIESMICH.txt. */
erstelltVon: string
erstelltAm?: Date
}
export type VollexportErgebnis = {
dateiname: string
/** Node-Lesestrom mit dem ZIP. Der Aufrufer reicht ihn an die Antwort weiter. */
strom: Readable
}
type Archiv = ReturnType<typeof archiver>
// -----------------------------------------------------------------------------
// Anhaengen mit Gegendruck und Pruefsumme
// -----------------------------------------------------------------------------
function sha256(inhalt: Buffer | string): string {
return createHash('sha256').update(inhalt).digest('hex')
}
/**
* Haengt einen Eintrag an und wartet, bis er geschrieben ist. Ohne dieses
* Warten staut sich der gesamte Bestand im Arbeitsspeicher, sobald die
* Gegenstelle langsamer ist als die Festplatte.
*/
function anhaengen(archiv: Archiv, pfad: string, inhalt: Buffer | string): Promise<void> {
pfadErfordern(pfad)
return new Promise<void>((erfuellen, ablehnen) => {
const aufraeumen = () => {
archiv.off('entry', beiEintrag)
archiv.off('error', beiFehler)
}
const beiEintrag = (eintrag: { name?: string }) => {
if (eintrag?.name !== pfad) return
aufraeumen()
erfuellen()
}
const beiFehler = (fehler: Error) => {
aufraeumen()
ablehnen(fehler)
}
archiv.on('entry', beiEintrag)
archiv.on('error', beiFehler)
archiv.append(inhalt, { name: pfad })
})
}
/** JSON-fest machen: BigInt wird Zeichenkette, Date wird ISO-Zeitstempel. */
export function alsJson(wert: unknown): string {
return JSON.stringify(
wert,
(_schluessel, inhalt) => (typeof inhalt === 'bigint' ? inhalt.toString() : inhalt),
2,
)
}
/**
* Entfernt die Umschlagfelder und legt stattdessen den Klartext daneben.
* Im Export soll lesbar sein, was in der Anwendung lesbar ist - ein
* verschluesselter String nuetzt niemandem, der das Archiv oeffnet.
*/
export function entschluesseln<T extends Record<string, unknown>>(
modell: VerschluesseltesModell | null,
datensaetze: T[],
): Record<string, unknown>[] {
if (!modell) return datensaetze as Record<string, unknown>[]
const felder = VERSCHLUESSELTE_FELDER[modell] as readonly string[]
return datensaetze.map((datensatz) => {
const geoeffnet = { ...oeffnen(modell, datensatz) } as Record<string, unknown>
for (const feld of felder) delete geoeffnet[feld]
return geoeffnet
})
}
// -----------------------------------------------------------------------------
// Tabellen
// -----------------------------------------------------------------------------
type Tabelle = { name: string; modell: VerschluesseltesModell | null; laden: () => Promise<unknown[]> }
/**
* Alle Tabellen, die zu einer betreuten Person gehoeren. Jede Abfrage ist
* auf die betreuteId eingegrenzt - direkt oder ueber die Beziehung, an der
* sie haengt. Der Papierkorb wird mit ausgegeben: Was geloescht ist, bleibt
* dreissig Tage lang wiederherstellbar, und der Export soll die Lage
* abbilden, nicht schoenen. Das Feld "geloeschtAm" sagt, woran man ist.
*/
function tabellen(betreuteId: string, optionen: VollexportOptionen): Tabelle[] {
const liste: Tabelle[] = [
{
name: 'betreute',
modell: 'Betreute',
laden: () => db.betreute.findMany({ where: { id: betreuteId } }),
},
{
name: 'betreuungszugang',
modell: null,
laden: () =>
db.betreuungsZugang.findMany({
where: { betreuteId },
include: { user: { select: { name: true, email: true, rolle: true } } },
}),
},
{ name: 'vorgang', modell: 'Vorgang', laden: () => db.vorgang.findMany({ where: { betreuteId } }) },
{
name: 'aufgabe',
modell: null,
laden: () => db.aufgabe.findMany({ where: { vorgang: { betreuteId } } }),
},
{
name: 'genehmigung',
modell: 'Genehmigung',
laden: () => db.genehmigung.findMany({ where: { betreuteId } }),
},
{ name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) },
{
name: 'dokument',
modell: 'Dokument',
laden: () =>
db.dokument.findMany({
where: { betreuteId },
// Der Suchvektor ist ein Datenbankinterna und nicht exportierbar.
select: {
id: true,
titel: true,
art: true,
richtung: true,
schreibenVom: true,
eingangAm: true,
versandAm: true,
absender: true,
empfaenger: true,
aktenzeichen: true,
objektSchluessel: true,
dateiname: true,
mimeTyp: true,
groesseBytes: true,
pruefsumme: true,
seitenzahl: true,
ocrText: true,
ocrStatus: true,
posteingangStatus: true,
ablageortPapier: true,
wiedervorlageAm: true,
ablaufAm: true,
notizenVerschluesselt: true,
erstelltAm: true,
aktualisiertAm: true,
geloeschtAm: true,
},
}),
},
{
name: 'dokument_verknuepfung',
modell: null,
laden: () => db.dokumentVerknuepfung.findMany({ where: { betreuteId } }),
},
{ name: 'tag', modell: null, laden: () => db.tag.findMany({ where: { betreuteId } }) },
{ name: 'konto', modell: 'Konto', laden: () => db.konto.findMany({ where: { betreuteId } }) },
{
name: 'saldostand',
modell: null,
laden: () => db.saldostand.findMany({ where: { konto: { betreuteId } } }),
},
{ name: 'buchung', modell: 'Buchung', laden: () => db.buchung.findMany({ where: { betreuteId } }) },
{
name: 'vermoegensposten',
modell: 'Vermoegensposten',
laden: () => db.vermoegensposten.findMany({ where: { betreuteId } }),
},
{
name: 'wertstand',
modell: null,
laden: () => db.wertstand.findMany({ where: { vermoegensposten: { betreuteId } } }),
},
{
name: 'immobilie',
modell: 'Immobilie',
laden: () => db.immobilie.findMany({ where: { betreuteId } }),
},
{
name: 'belastung',
modell: null,
laden: () => db.belastung.findMany({ where: { immobilie: { betreuteId } } }),
},
{
name: 'immobilien_checkpunkt',
modell: null,
laden: () => db.immobilienCheckpunkt.findMany({ where: { immobilie: { betreuteId } } }),
},
{ name: 'vertrag', modell: 'Vertrag', laden: () => db.vertrag.findMany({ where: { betreuteId } }) },
{ name: 'kontakt', modell: 'Kontakt', laden: () => db.kontakt.findMany({ where: { betreuteId } }) },
{
name: 'kontaktprotokoll',
modell: 'Kontaktprotokoll',
laden: () => db.kontaktprotokoll.findMany({ where: { betreuteId } }),
},
{
name: 'gesundheitsereignis',
modell: 'Gesundheitsereignis',
laden: () => db.gesundheitsereignis.findMany({ where: { betreuteId } }),
},
{
name: 'heimvertrag',
modell: 'Heimvertrag',
laden: () => db.heimvertrag.findMany({ where: { betreuteId } }),
},
{
name: 'heimkostenmonat',
modell: null,
laden: () => db.heimkostenmonat.findMany({ where: { betreuteId } }),
},
{
name: 'sozialhilfe_antrag',
modell: 'SozialhilfeAntrag',
laden: () => db.sozialhilfeAntrag.findMany({ where: { betreuteId } }),
},
{
name: 'nachforderung',
modell: null,
laden: () => db.nachforderung.findMany({ where: { antrag: { betreuteId } } }),
},
{
name: 'bescheid',
modell: 'Bescheid',
laden: () => db.bescheid.findMany({ where: { antrag: { betreuteId } } }),
},
{ name: 'leistung', modell: null, laden: () => db.leistung.findMany({ where: { betreuteId } }) },
{ name: 'bericht', modell: 'Bericht', laden: () => db.bericht.findMany({ where: { betreuteId } }) },
]
if (optionen.mitAuditLog) {
liste.push({
name: 'audit_log',
modell: 'AuditLog',
laden: () => db.auditLog.findMany({ where: { betreuteId }, orderBy: { zeitpunkt: 'asc' } }),
})
}
return liste
}
// -----------------------------------------------------------------------------
// LIESMICH
// -----------------------------------------------------------------------------
export function liesmichText(angaben: {
betreuteName: string
erstelltVon: string
erstelltAm: Date
tabellen: string[]
anzahlDokumente: number
anzahlBerichte: number
mitAuditLog: boolean
}): string {
return [
'Vollexport aus dem Betreuungs-Cockpit',
'=====================================',
'',
`Betreute Person: ${angaben.betreuteName}`,
`Erzeugt am: ${formatDatumZeit(angaben.erstelltAm)}`,
`Erzeugt von: ${angaben.erstelltVon}`,
'',
'Was ist hier drin?',
'------------------',
'',
'dokumente/<jahr>/<datum>_<absender>_<titel>.<endung>',
' Alle hochgeladenen Originaldateien - Bescheide, Kontoauszüge, Rechnungen,',
' Beschlüsse, Fotos. Unverändert so, wie sie eingegangen sind.',
` Enthalten: ${angaben.anzahlDokumente} Dateien.`,
'',
'daten/*.json',
' Der komplette Datenbestand als JSON, eine Datei je Tabelle. Verschlüsselte',
' Felder sind hier im Klartext enthalten (z.B. "notizen" statt',
' "notizenVerschluesselt"). Geldbeträge stehen als Zeichenkette in CENT -',
' "123456" bedeutet 1.234,56 Euro. Datumsangaben sind ISO-8601 (UTC).',
` Enthalten: ${angaben.tabellen.join(', ')}.`,
angaben.mitAuditLog
? ' Das Audit-Log protokolliert, wer wann was geändert hat.'
: ' Das Audit-Log wurde für diesen Export abgewählt.',
'',
'berichte/*.pdf',
' Die erzeugten und abgelegten Berichte - Jahresberichte, Vermögensverzeichnis,',
` Belegmappen, Unterlagenpakete. Enthalten: ${angaben.anzahlBerichte} Dateien.`,
'',
'pruefsummen.txt',
' SHA-256 je Datei in diesem Archiv, im Format "<hash> <pfad>". Damit lässt',
' sich jederzeit belegen, dass eine Datei unverändert ist. Prüfen kannst du',
' das unter Linux/macOS mit "shasum -a 256 -c pruefsummen.txt", unter Windows',
' mit "certutil -hashfile <datei> SHA256".',
'',
'Wie arbeite ich ohne diese Anwendung weiter?',
'-------------------------------------------',
'',
'1. Entpacke das Archiv in einen Ordner, den du sicherst (Backup!). Die Daten',
' sind hier NICHT mehr verschlüsselt - behandle den Ordner so vertraulich wie',
' den Aktenordner selbst.',
'2. Die Ordnerstruktur unter dokumente/ ist nach Jahren sortiert und selbst',
' erklärend. Für das Betreuungsgericht und den Sozialhilfeträger reicht sie',
' als Aktenführung aus.',
'3. Die JSON-Dateien öffnest du mit jedem Texteditor. Wer eine Tabelle daraus',
' machen will, lädt sie in LibreOffice Calc oder Excel (Daten -> Aus Datei ->',
' JSON) oder nutzt ein Werkzeug wie "jq".',
'4. Die laufenden Pflichten bleiben bestehen, auch ohne Software: Jahresbericht',
' nach § 1863 BGB, Vermögensübersicht nach § 1859 BGB, die Aufwandspauschale',
' nach § 1878 BGB innerhalb von sechs Monaten nach Ende des Betreuungsjahres',
' (Ausschlussfrist!). Übertrage die Fristen aus daten/frist.json in deinen',
' Kalender, bevor du die Anwendung abschaltest.',
'',
'Dieses Archiv ist keine Rechtsberatung und ersetzt keine Rücksprache mit dem',
'Betreuungsgericht, dem Sozialhilfeträger oder einem Anwalt.',
'',
].join('\n')
}
// -----------------------------------------------------------------------------
// Der Export selbst
// -----------------------------------------------------------------------------
export async function vollexportStroemen(optionen: VollexportOptionen): Promise<VollexportErgebnis> {
const erstelltAm = optionen.erstelltAm ?? new Date()
const betreute = await db.betreute.findFirstOrThrow({
where: { id: optionen.betreuteId, ...nichtGeloescht },
select: { vorname: true, nachname: true, anrede: true },
})
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
.filter(Boolean)
.join(' ')
const archiv = archiver('zip', { zlib: { level: 9 } })
// Fehlende Dateien im Objektspeicher duerfen den Lauf nicht abbrechen.
archiv.on('warning', (warnung) => {
console.warn('[vollexport] Warnung beim Archivieren:', warnung.message)
})
const dateiname = berichtDateiname('vollexport', betreute.nachname, erstelltAm, 'zip')
// Der Aufbau laeuft im Hintergrund weiter, waehrend der Aufrufer bereits
// den Strom weiterreicht - genau das macht den Export streamfaehig.
void befuellen(archiv, optionen, { betreuteName, erstelltAm }).catch((fehler: Error) => {
console.error('[vollexport] Abbruch:', fehler)
archiv.abort()
})
return { dateiname, strom: archiv }
}
async function befuellen(
archiv: Archiv,
optionen: VollexportOptionen,
zusatz: { betreuteName: string; erstelltAm: Date },
): Promise<void> {
const pruefsummen: { pfad: string; hash: string }[] = []
const vergeben = new Set<string>()
/** Verhindert, dass zwei Dokumente mit gleichem Titel einander verdecken. */
const eindeutig = (pfad: string): string => {
if (!vergeben.has(pfad)) {
vergeben.add(pfad)
return pfad
}
const punkt = pfad.lastIndexOf('.')
const stamm = punkt < 0 ? pfad : pfad.slice(0, punkt)
const endung = punkt < 0 ? '' : pfad.slice(punkt)
let n = 2
while (vergeben.has(`${stamm}-${n}${endung}`)) n++
const neu = `${stamm}-${n}${endung}`
vergeben.add(neu)
return neu
}
const schreiben = async (pfad: string, inhalt: Buffer | string) => {
const ziel = eindeutig(pfadErfordern(pfad))
pruefsummen.push({ pfad: ziel, hash: sha256(inhalt) })
await anhaengen(archiv, ziel, inhalt)
}
// --- 1. Daten ------------------------------------------------------------
const verwendeteTabellen: string[] = []
for (const tabelle of tabellen(optionen.betreuteId, optionen)) {
const datensaetze = (await tabelle.laden()) as Record<string, unknown>[]
const offen = entschluesseln(tabelle.modell, datensaetze)
await schreiben(`daten/${sichererDateiname(tabelle.name, 'tabelle')}.json`, alsJson(offen))
verwendeteTabellen.push(tabelle.name)
}
// --- 2. Dokumente --------------------------------------------------------
let anzahlDokumente = 0
if (optionen.mitDokumenten) {
const dokumente = await db.dokument.findMany({
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
select: {
titel: true,
absender: true,
schreibenVom: true,
eingangAm: true,
erstelltAm: true,
objektSchluessel: true,
dateiname: true,
mimeTyp: true,
},
orderBy: { erstelltAm: 'asc' },
})
for (const dokument of dokumente) {
if (!dokument.objektSchluessel) continue
let inhalt: Buffer
try {
inhalt = await lesen(dokument.objektSchluessel)
} catch (fehler) {
console.warn(
`[vollexport] Datei zu "${dokument.titel}" nicht lesbar, wird übersprungen:`,
(fehler as Error).message,
)
continue
}
const pfad = exportDokumentPfad({
datum: dokument.schreibenVom ?? dokument.eingangAm ?? dokument.erstelltAm,
absender: dokument.absender,
titel: dokument.titel,
dateiname: dokument.dateiname,
mimeTyp: dokument.mimeTyp,
})
await schreiben(pfad, inhalt)
anzahlDokumente++
}
}
// --- 3. Berichte ---------------------------------------------------------
let anzahlBerichte = 0
if (optionen.mitBerichten) {
const berichte = await db.bericht.findMany({
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
select: { titel: true, art: true, dateiname: true, objektSchluessel: true, erstelltAm: true },
orderBy: { erstelltAm: 'asc' },
})
for (const bericht of berichte) {
if (!bericht.objektSchluessel) continue
let inhalt: Buffer
try {
inhalt = await lesen(bericht.objektSchluessel)
} catch (fehler) {
console.warn(
`[vollexport] Bericht "${bericht.titel}" nicht lesbar, wird übersprungen:`,
(fehler as Error).message,
)
continue
}
const name = bericht.dateiname
? sichererDateiname(bericht.dateiname.replace(/\.[a-z0-9]+$/i, ''), 'bericht')
: sichererDateiname(`${bericht.art}-${bericht.titel}`, 'bericht')
await schreiben(
`berichte/${bericht.erstelltAm.toISOString().slice(0, 10)}_${name}.pdf`,
inhalt,
)
anzahlBerichte++
}
}
// --- 4. LIESMICH und Pruefsummen ----------------------------------------
await schreiben(
'LIESMICH.txt',
liesmichText({
betreuteName: zusatz.betreuteName,
erstelltVon: optionen.erstelltVon,
erstelltAm: zusatz.erstelltAm,
tabellen: verwendeteTabellen,
anzahlDokumente,
anzahlBerichte,
mitAuditLog: optionen.mitAuditLog,
}),
)
// Die Pruefsummendatei steht zuletzt und listet alles davor - sie selbst
// kann sich naturgemaess nicht enthalten.
const zeilen = [
`# SHA-256 aller Dateien in diesem Archiv, erzeugt am ${formatDatum(zusatz.erstelltAm)}.`,
'# Prüfen: shasum -a 256 -c pruefsummen.txt',
...pruefsummen.map((eintrag) => `${eintrag.hash} ${eintrag.pfad}`),
'',
].join('\n')
await anhaengen(archiv, 'pruefsummen.txt', zeilen)
await archiv.finalize()
}
+350
View File
@@ -0,0 +1,350 @@
import 'server-only'
import * as React from 'react'
import { Document, Text, View } from '@react-pdf/renderer'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
bildAlsPdf,
paginieren,
pdfErzeugen,
pdfsZusammenfuehren,
seitenzahl as seitenzahlVon,
} from '@/lib/pdf/erzeugen'
import { Abschnitt, Angabe, Berichtsseite, Hinweiskasten, Tabelle, type Berichtskopf } from '@/lib/pdf/rahmen'
import { typErkennen } from '@/lib/speicher/dateityp'
import { lesen } from '@/lib/speicher'
/**
* Belegmappe: alle Dokumente eines Vorgangs oder eines Zeitraums als **eine**
* chronologisch sortierte, durchgehend paginierte PDF.
*
* Genau das wollen Gericht und Bezirk sehen - nicht dreissig einzelne
* Anhaenge, sondern ein Heft mit Deckblatt, Inhaltsverzeichnis und
* fortlaufender Seitenzahl, auf die man sich im Bericht berufen kann
* ("Beleg 7, Seite 23").
*
* Bilder (abfotografierte Belege) werden in einseitige PDF-Seiten gewandelt,
* damit die Mappe in einem Stueck bleibt.
*/
export type Belegmappenauswahl =
| { art: 'vorgang'; vorgangId: string }
| { art: 'zeitraum'; von: Date; bis: Date }
| { art: 'auswahl'; dokumentIds: string[] }
export type Mappenergebnis = {
pdf: Buffer
dateiname: string
/** Wie viele Dokumente sind in der Mappe gelandet? */
anzahl: number
/** Was konnte nicht aufgenommen werden - steht auch im Deckblatt. */
hinweise: string[]
}
type Belegzeile = {
nummer: number
dokumentId: string
datum: Date | null
absender: string
art: string
titel: string
aktenzeichen: string | null
ablageortPapier: string | null
pruefsumme: string | null
seiteVon: number
seiten: number
}
/** Das Datum, nach dem chronologisch sortiert wird. */
function massgeblichesDatum(dokument: {
eingangAm: Date | null
schreibenVom: Date | null
erstelltAm: Date
}): Date {
return dokument.eingangAm ?? dokument.schreibenVom ?? dokument.erstelltAm
}
async function dokumenteHolen(betreuteId: string, auswahl: Belegmappenauswahl) {
const gemeinsam = { betreuteId, ...nichtGeloescht, objektSchluessel: { not: null } } as const
if (auswahl.art === 'vorgang') {
return db.dokument.findMany({
where: { ...gemeinsam, verknuepfungen: { some: { vorgangId: auswahl.vorgangId } } },
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
if (auswahl.art === 'auswahl') {
return db.dokument.findMany({
where: { ...gemeinsam, id: { in: auswahl.dokumentIds.slice(0, 200) } },
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
return db.dokument.findMany({
where: {
...gemeinsam,
OR: [
{ eingangAm: { gte: auswahl.von, lte: auswahl.bis } },
{ eingangAm: null, schreibenVom: { gte: auswahl.von, lte: auswahl.bis } },
{ eingangAm: null, schreibenVom: null, erstelltAm: { gte: auswahl.von, lte: auswahl.bis } },
],
},
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
// -----------------------------------------------------------------------------
// Deckblatt und Inhaltsverzeichnis
// -----------------------------------------------------------------------------
function Deckblatt({
kopf,
zeilen,
anzahlSeiten,
hinweise,
beschreibung,
}: {
kopf: Berichtskopf
zeilen: Belegzeile[]
anzahlSeiten: number
hinweise: string[]
beschreibung: string
}): React.JSX.Element {
return (
<Document
title={`Belegmappe – ${kopf.betreute.name}`}
author={kopf.erstelltVon}
creator="Betreuungs-Cockpit"
producer="Betreuungs-Cockpit"
>
<Berichtsseite kopf={kopf} mitUnterschrift>
<Abschnitt titel="Inhalt dieser Mappe">
<Angabe bezeichnung="Zusammenstellung" wert={beschreibung} />
<Angabe bezeichnung="Anzahl Belege" wert={String(zeilen.length)} />
<Angabe
bezeichnung="Umfang"
wert={`${anzahlSeiten} Seite${anzahlSeiten === 1 ? '' : 'n'} einschließlich dieses Deckblatts`}
/>
<Angabe bezeichnung="Zusammengestellt am" wert={formatDatum(kopf.erstelltAm)} />
</Abschnitt>
<Abschnitt titel="Verzeichnis der Belege">
<Tabelle<Belegzeile>
spalten={[
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
{ kopf: 'Datum', breite: 1.5, zelle: (z) => formatDatum(z.datum) },
{ kopf: 'Absender', breite: 3, zelle: (z) => z.absender },
{ kopf: 'Art', breite: 2, zelle: (z) => z.art },
{ kopf: 'Bezeichnung', breite: 4, zelle: (z) => z.titel },
{
kopf: 'Seite',
breite: 1.3,
rechts: true,
zelle: (z) =>
z.seiten > 1 ? `${z.seiteVon}–${z.seiteVon + z.seiten - 1}` : String(z.seiteVon),
},
]}
zeilen={zeilen}
leerText="Für diese Auswahl liegen keine Belege vor."
/>
</Abschnitt>
{zeilen.some((z) => z.ablageortPapier) && (
<Abschnitt titel="Ablageort der Originale">
<Tabelle<Belegzeile>
spalten={[
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
{ kopf: 'Bezeichnung', breite: 5, zelle: (z) => z.titel },
{ kopf: 'Ordner / Register', breite: 4, zelle: (z) => z.ablageortPapier ?? '–' },
]}
zeilen={zeilen.filter((z) => z.ablageortPapier)}
/>
</Abschnitt>
)}
{hinweise.length > 0 && (
<Abschnitt titel="Hinweise">
{hinweise.map((hinweis, i) => (
<Text key={i} style={{ marginBottom: 3 }}>
• {hinweis}
</Text>
))}
</Abschnitt>
)}
<View>
<Hinweiskasten>
Die Belege sind Kopien der bei der Betreuung eingegangenen Unterlagen. Zu jedem Beleg
ist im Betreuungs-Cockpit eine SHA-256-Prüfsumme gespeichert; damit lässt sich
nachweisen, dass die Datei seit der Aufnahme in die Akte nicht verändert wurde. Die
Originale in Papierform sind unter dem oben genannten Ablageort abgeheftet.
</Hinweiskasten>
</View>
</Berichtsseite>
</Document>
)
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Stellt die Mappe zusammen.
*
* Das Deckblatt wird zweimal gerendert: einmal, um seinen eigenen Umfang zu
* kennen, und dann noch einmal mit den richtigen Seitenzahlen. Anders geht
* es nicht - das Verzeichnis muss auf Seiten verweisen, die es selbst
* mitverschiebt.
*/
export async function belegmappeErzeugen(
betreuteId: string,
auswahl: Belegmappenauswahl,
erstelltVon: string,
): Promise<Mappenergebnis> {
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: {
vorname: true,
nachname: true,
geburtsdatum: true,
aktenzeichenGericht: true,
betreuungsgericht: true,
},
})
if (!betreute) throw new Error('Diese betreute Person gibt es nicht.')
const dokumente = await dokumenteHolen(betreuteId, auswahl)
const hinweise: string[] = []
// Jedes Dokument in ein PDF wandeln und seinen Umfang bestimmen.
const teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'>; pdf: Buffer }[] = []
for (const dokument of dokumente) {
if (!dokument.objektSchluessel) continue
try {
const inhalt = await lesen(dokument.objektSchluessel)
const typ = typErkennen(inhalt)
if (!typ) {
hinweise.push(`„${dokument.titel}“ konnte nicht gelesen werden und fehlt in dieser Mappe.`)
continue
}
const alsPdf = typ.istBild
? await bildAlsPdf(
inhalt,
`${dokument.titel} · ${formatDatum(massgeblichesDatum(dokument))}`,
)
: inhalt
const seiten = await seitenzahlVon(alsPdf)
teile.push({
pdf: alsPdf,
zeile: {
dokumentId: dokument.id,
datum: dokument.eingangAm ?? dokument.schreibenVom,
absender: dokument.absender ?? '–',
art: ART_BESCHRIFTUNG[dokument.art],
titel: dokument.titel,
aktenzeichen: dokument.aktenzeichen,
ablageortPapier: dokument.ablageortPapier,
pruefsumme: dokument.pruefsumme,
seiten,
},
})
} catch (fehler) {
console.error('[belegmappe] Dokument übersprungen:', dokument.id, fehler)
hinweise.push(
`„${dokument.titel}“ ließ sich nicht in die Mappe übernehmen (Datei beschädigt oder verschlüsselt).`,
)
}
}
const kopf: Berichtskopf = {
titel: 'Belegmappe',
betreute: {
name: `${betreute.vorname} ${betreute.nachname}`,
geburtsdatum: betreute.geburtsdatum,
},
behoerde: betreute.betreuungsgericht ?? undefined,
aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
zeitraumVon: auswahl.art === 'zeitraum' ? auswahl.von : null,
zeitraumBis: auswahl.art === 'zeitraum' ? auswahl.bis : null,
rechtsgrundlage: '§ 1865 BGB (Rechnungslegung)',
erstelltAm: new Date(),
erstelltVon,
}
const beschreibung = beschreibungFuer(auswahl)
// Zwei Durchgaenge, damit die Seitenzahlen im Verzeichnis stimmen.
let deckblattSeiten = 1
let deckblatt: Buffer = Buffer.alloc(0)
for (let durchgang = 0; durchgang < 3; durchgang++) {
const zeilen = zeilenNummerieren(teile, deckblattSeiten)
const gesamt = deckblattSeiten + teile.reduce((summe, teil) => summe + teil.zeile.seiten, 0)
deckblatt = await pdfErzeugen(
<Deckblatt
kopf={kopf}
zeilen={zeilen}
anzahlSeiten={gesamt}
hinweise={hinweise}
beschreibung={beschreibung}
/>,
)
const gemessen = await seitenzahlVon(deckblatt)
if (gemessen === deckblattSeiten) break
deckblattSeiten = gemessen
}
const zusammengefuehrt = await pdfsZusammenfuehren([deckblatt, ...teile.map((t) => t.pdf)])
const fusszeile = `Belegmappe · ${betreute.vorname} ${betreute.nachname}${
betreute.aktenzeichenGericht ? ` · Az. ${betreute.aktenzeichenGericht}` : ''
} · erstellt am ${formatDatum(kopf.erstelltAm)}`
const pdf = await paginieren(zusammengefuehrt, fusszeile)
return {
pdf,
dateiname: dateinamenFuer(auswahl, betreute.nachname),
anzahl: teile.length,
hinweise,
}
}
function zeilenNummerieren(
teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'> }[],
ersteSeite: number,
): Belegzeile[] {
let seite = ersteSeite + 1
return teile.map((teil, i) => {
const zeile: Belegzeile = { ...teil.zeile, nummer: i + 1, seiteVon: seite }
seite += teil.zeile.seiten
return zeile
})
}
function beschreibungFuer(auswahl: Belegmappenauswahl): string {
if (auswahl.art === 'vorgang') return 'Alle Belege zu einem Vorgang'
if (auswahl.art === 'auswahl') return 'Von Hand zusammengestellte Belege'
return `Belege im Zeitraum ${formatDatum(auswahl.von)} bis ${formatDatum(auswahl.bis)}`
}
function dateinamenFuer(auswahl: Belegmappenauswahl, nachname: string): string {
const teil =
auswahl.art === 'zeitraum'
? `${auswahl.von.toISOString().slice(0, 10)}_bis_${auswahl.bis.toISOString().slice(0, 10)}`
: auswahl.art === 'vorgang'
? 'vorgang'
: 'auswahl'
const sauber = nachname.replace(/[^A-Za-z0-9]/g, '') || 'Akte'
return `Belegmappe_${sauber}_${teil}.pdf`
}
+88 -21
View File
@@ -124,6 +124,39 @@ export function normalisieren(text: string): string {
.replace(/ß/g, 'ss')
}
/**
* Vergleichsform fuer den Namensabgleich: kleingeschrieben, ohne Umlaute,
* und jede Folge von Satz- oder Leerzeichen wird zu einem Leerzeichen.
*
* Damit findet "Bezirk Oberfranken, Sozialverwaltung" aus den Kontakten
* auch den Briefkopf, in dem beides in zwei Zeilen ohne Komma steht.
*
* Der Index zeigt fuer jedes Zeichen der Vergleichsform auf die Stelle im
* Ursprungstext - so laesst sich hinterher die Textstelle ausschneiden.
*/
export function vergleichsform(text: string): { norm: string; index: number[] } {
let norm = ''
const index: number[] = []
let trennerOffen = true
for (let i = 0; i < text.length; i++) {
const ersetzt = normalisieren(text[i]!)
if (/^[a-z0-9]+$/.test(ersetzt)) {
for (const zeichen of ersetzt) {
norm += zeichen
index.push(i)
}
trennerOffen = false
} else if (!trennerOffen) {
norm += ' '
index.push(i)
trennerOffen = true
}
}
return { norm, index }
}
function jahrVervollstaendigen(roh: string): number {
const zahl = Number.parseInt(roh, 10)
if (roh.length === 4) return zahl
@@ -287,7 +320,7 @@ const AZ_MUSTER: AzMuster[] = [
{
// "Az.: XVII 123/25", "Aktenzeichen: 4711/26", "Unser Zeichen: AB/12345"
regex:
/(?:Aktenzeichen|Az\.?|Gesch\.?[-\s]?Z\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
/\b(?:Aktenzeichen|Az\b\.?|Gesch\.?[-\s]?Z\b\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
sicherheit: 'hoch',
gruppe: 1,
},
@@ -299,8 +332,10 @@ const AZ_MUSTER: AzMuster[] = [
gruppe: 1,
},
{
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025"
regex: /\b((?:X{0,3}(?:IX|IV|V?I{0,3}))\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025".
// Die roemische Zahl ist Pflicht - sonst wuerde jedes "12/2025" im
// Text als Aktenzeichen durchgehen.
regex: /\b([IVXL]{1,6}\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
sicherheit: 'mittel',
gruppe: 1,
},
@@ -477,22 +512,37 @@ export function absenderErkennen(
bekannte: string[] = [],
): Vorschlag<string> | null {
const kopfbereich = text.slice(0, 1200)
const normKopf = normalisieren(kopfbereich)
const kopfForm = vergleichsform(kopfbereich)
// 1. Abgleich mit bekannten Absendern
let bester: { name: string; stelle: number } | null = null
// 1. Abgleich mit bekannten Absendern. Wer schon einmal geschrieben hat,
// schreibt wieder - deshalb schlaegt das jede Heuristik.
let bester: { name: string; stelle: number; laenge: number } | null = null
for (const name of bekannte) {
const gekuerzt = name.trim()
if (gekuerzt.length < 4) continue
const stelle = normKopf.indexOf(normalisieren(gekuerzt))
if (stelle >= 0 && (!bester || gekuerzt.length > bester.name.length)) {
bester = { name: gekuerzt, stelle }
const gesucht = vergleichsform(gekuerzt).norm.trim()
if (gesucht.length < 4) continue
const treffer = kopfForm.norm.indexOf(gesucht)
if (treffer < 0) continue
if (bester && gekuerzt.length <= bester.laenge) continue
bester = {
name: gekuerzt,
stelle: kopfForm.index[treffer] ?? 0,
laenge: gekuerzt.length,
}
}
if (bester) {
return {
wert: bester.name,
textstelle: textstelleSchneiden(kopfbereich, bester.stelle, bester.stelle + bester.name.length, 40),
textstelle: textstelleSchneiden(
kopfbereich,
bester.stelle,
bester.stelle + bester.laenge,
40,
),
sicherheit: 'hoch',
}
}
@@ -576,17 +626,26 @@ function anzahlLesen(roh: string): number | null {
return wort ?? null
}
/**
* Fristen werden ueber date-fns gerechnet - aber auf einem Datum in der
* lokalen Zeitzone, mittags. Sonst verschoebe die Sommerzeit eine Frist um
* einen Tag: der 12.03. (Winterzeit) plus vier Wochen ist der 09.04.
* (Sommerzeit), und wer das in UTC rechnet, landet auf dem 08.04. Genau
* dieser Fehler kostet im Ernstfall einen Widerspruch.
*/
function rechnen(basis: Date, anzahl: number, einheit: Einheit): Date {
switch (einheit) {
case 'tage':
return aufTag(addDays(basis, anzahl))
case 'wochen':
return aufTag(addWeeks(basis, anzahl))
case 'monate':
return aufTag(addMonths(basis, anzahl))
case 'jahre':
return aufTag(addYears(basis, anzahl))
}
const lokal = new Date(basis.getUTCFullYear(), basis.getUTCMonth(), basis.getUTCDate(), 12)
const ergebnis =
einheit === 'tage'
? addDays(lokal, anzahl)
: einheit === 'wochen'
? addWeeks(lokal, anzahl)
: einheit === 'monate'
? addMonths(lokal, anzahl)
: addYears(lokal, anzahl)
return tag(ergebnis.getFullYear(), ergebnis.getMonth() + 1, ergebnis.getDate())
}
function einheitText(anzahl: number, einheit: Einheit): string {
@@ -666,6 +725,10 @@ export function fristenErkennen(text: string, basis: Date | null): Fristvorschla
const fund = datenFinden(rest)[0]
if (!fund || fund.von > 3) continue
// "vom 01.01.2025 bis 31.12.2025" ist ein Zeitraum, keine Frist.
const davor = text.slice(Math.max(0, treffer.index - 40), treffer.index)
if (/(?:vom|von)\s+[\d.]{4,12}\s*$/i.test(davor)) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), ab + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const zahlung = /zahl|betrag|überweis|ueberweis|konto|rechnung/i.test(umgebung)
@@ -703,7 +766,11 @@ function entdoppeln(liste: Fristvorschlag[]): Fristvorschlag[] {
.slice()
.sort((a, b) => rang[a.sicherheit] - rang[b.sicherheit])
.filter((vorschlag) => {
const schluessel = `${vorschlag.datum?.toISOString() ?? 'offen'}|${normalisieren(vorschlag.formulierung)}`
// Zwei Formulierungen, die auf denselben Tag hinauslaufen, sind eine
// Frist. Der sicherere Fund gewinnt - deshalb vorher sortiert.
const schluessel = vorschlag.datum
? vorschlag.datum.toISOString()
: normalisieren(vorschlag.formulierung)
if (gesehen.has(schluessel)) return false
gesehen.add(schluessel)
return true
+44
View File
@@ -0,0 +1,44 @@
import 'server-only'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Der gemeinsame Einstieg aller Dokumentenseiten.
*
* Jede Seite beginnt hier - und damit bei der Zugriffspruefung. Es gibt
* keinen Weg an `erfordereZugang` vorbei: die aktive betreute Person kommt
* aus dem Cookie, aber ob sie angezeigt werden darf, entscheidet die
* Datenbank.
*/
export async function dokumentenkontext(art: 'lesen' | 'schreiben' = 'lesen') {
const benutzer = await erfordereBenutzer('/dokumente')
const gewaehlt = await aktiveBetreute(benutzer)
if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.')
const kontext = await erfordereZugang(gewaehlt.id, 'DOKUMENTE', art)
return { ...kontext, betreuteId: gewaehlt.id }
}
/** Alle Schlagworte dieser Akte - fuer Filter und Formulare. */
export async function tagsDerAkte(betreuteId: string) {
return db.tag.findMany({
where: { betreuteId },
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
}
/** Offene und laufende Vorgaenge - Ziel der schnellen Zuordnung. */
export async function vorgaengeDerAkte(betreuteId: string) {
const vorgaenge = await db.vorgang.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, status: true },
orderBy: [{ status: 'asc' }, { faelligAm: 'asc' }, { erstelltAm: 'desc' }],
take: 100,
})
return vorgaenge.map((vorgang) => ({
id: vorgang.id,
titel: vorgang.status === 'ERLEDIGT' ? `${vorgang.titel} (erledigt)` : vorgang.titel,
}))
}
+89
View File
@@ -0,0 +1,89 @@
import 'server-only'
import { db } from '@/lib/db'
import { ocrLesen } from '@/lib/ocr'
import { lesen } from '@/lib/speicher'
/**
* Texterkennung nach dem Hochladen.
*
* Der Upload wartet nicht darauf. Das Dokument wird mit
* `ocrStatus = "ausstehend"` gespeichert, danach laeuft die Erkennung und
* schreibt `ocrText` nach - den Suchvektor fuellt der Trigger in Postgres
* von selbst (prisma/sql/volltextsuche.sql).
*
* Warum nicht warten? Weil Tesseract fuer einen zwanzigseitigen Scan
* Minuten braucht. Wer am Heimbett steht und einen Bescheid abfotografiert,
* soll das Handy weglegen koennen, sobald das Bild oben ist.
*
* Diese Funktion wirft nie. Schlaegt sie fehl, bleibt ein Status stehen,
* der in der Oberflaeche erklaert wird, und das Dokument ist trotzdem in
* der Akte.
*/
export async function ocrNachholen(dokumentId: string): Promise<void> {
const dokument = await db.dokument.findUnique({
where: { id: dokumentId },
select: { id: true, objektSchluessel: true, mimeTyp: true, ocrStatus: true },
})
if (!dokument?.objektSchluessel) return
if (dokument.ocrStatus === 'laeuft') return
try {
await db.dokument.update({ where: { id: dokument.id }, data: { ocrStatus: 'laeuft' } })
const inhalt = await lesen(dokument.objektSchluessel)
const ergebnis = await ocrLesen(inhalt, dokument.mimeTyp ?? 'application/octet-stream')
await db.dokument.update({
where: { id: dokument.id },
data: {
ocrText: ergebnis.text.trim() === '' ? null : ergebnis.text,
ocrStatus: ergebnis.status,
// Die Seitenzahl kennt erst die Erkennung - vorher steht dort nichts.
...(ergebnis.seitenzahl ? { seitenzahl: ergebnis.seitenzahl } : {}),
},
})
} catch (fehler) {
console.error('[ocr] Erkennung fehlgeschlagen fuer', dokumentId, fehler)
try {
await db.dokument.update({
where: { id: dokument.id },
data: { ocrStatus: 'fehlgeschlagen' },
})
} catch {
// Auch das Zurueckschreiben darf nichts abbrechen.
}
}
}
/**
* Startet die Erkennung, ohne auf sie zu warten.
*
* Bewusst ohne Job-Warteschlange: diese Anwendung laeuft als ein einzelner
* Container fuer eine Handvoll Akten. Ein Hintergrundlauf pro Upload ist
* genau die richtige Groessenordnung; ein Redis daneben waere Ballast.
*/
export function ocrAnstossen(dokumentId: string): void {
void ocrNachholen(dokumentId).catch((fehler) => {
console.error('[ocr] Hintergrundlauf gescheitert:', fehler)
})
}
/** Alle haengengebliebenen Dokumente noch einmal versuchen. */
export async function ocrNachziehen(betreuteId: string, grenze = 20): Promise<number> {
const offene = await db.dokument.findMany({
where: {
betreuteId,
geloeschtAm: null,
objektSchluessel: { not: null },
OR: [{ ocrStatus: 'ausstehend' }, { ocrStatus: 'fehlgeschlagen' }, { ocrStatus: null }],
},
select: { id: true },
take: Math.min(Math.max(grenze, 1), 100),
})
for (const dokument of offene) {
await ocrNachholen(dokument.id)
}
return offene.length
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Aufbereitung der Sucheingabe - bewusst in einer eigenen, reinen Datei.
*
* `src/lib/dokumente/suche.ts` zieht die Datenbank hinter sich her; diese
* Regeln sollen sich aber ohne Postgres testen lassen. Wer sucht, importiert
* weiterhin aus suche.ts - dort wird von hier re-exportiert.
*/
/** Laenger sucht niemand von Hand, und die Datenbank dankt es. */
export const MAX_SUCHLAENGE = 200
/**
* Bereitet die Eingabe des Nutzers fuer `websearch_to_tsquery` auf.
*
* websearch_to_tsquery versteht die Schreibweise, die man aus Suchmaschinen
* kennt: einzelne Woerter, "Wortgruppen in Anfuehrungszeichen", `or` zum
* Verodern, ein vorangestelltes Minus zum Ausschliessen. Und es wirft bei
* keiner Eingabe - deshalb wird hier bewusst wenig veraendert.
*
* Was doch getan wird:
* - Steuerzeichen raus (kommen aus kopiertem OCR-Text)
* - Leerraum vereinheitlichen
* - Laenge begrenzen
* - ein einzelnes, offen gebliebenes Anfuehrungszeichen entfernen; sonst
* wuerde der halbe Rest zur Wortgruppe und der Nutzer wundert sich.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_SUCHLAENGE)
.trim()
if (sauber.length < 2) return null
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '').trim()
}
+10 -33
View File
@@ -1,6 +1,7 @@
import 'server-only'
import { Prisma, type DokumentArt, type DokumentRichtung, type PosteingangStatus } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff'
/**
* Volltextsuche ueber die Dokumente einer betreuten Person.
@@ -37,33 +38,10 @@ export type Suchtreffer = {
}
/**
* Bereitet die Eingabe des Nutzers auf.
*
* `websearch_to_tsquery` versteht die Schreibweise, die man aus
* Suchmaschinen kennt: Woerter, "Wortgruppen in Anfuehrungszeichen",
* `oder`, ein vorangestelltes Minus zum Ausschliessen. Deshalb wird hier
* bewusst wenig veraendert - nur Steuerzeichen und uebermaessige Laenge
* werden abgeraeumt.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
* Die Aufbereitung der Sucheingabe steht in einer eigenen, reinen Datei,
* damit sie sich ohne Datenbank testen laesst. Hier nur weitergereicht.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
if (sauber.length < 2) return null
// Ein einzelnes offenes Anfuehrungszeichen wuerde die halbe Anfrage zur
// Wortgruppe machen - lieber entfernen als den Nutzer raten lassen.
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '')
}
export { suchanfrageAufbereiten, MAX_SUCHLAENGE } from '@/lib/dokumente/suchbegriff'
export type Suchoptionen = {
grenze?: number
@@ -315,13 +293,12 @@ export async function dokumenteListe(
})
const nachId = new Map(datensaetze.map((d) => [d.id, d]))
const eintraege = seitenTreffer
.map((t) => {
const datensatz = nachId.get(t.id)
if (!datensatz) return null
return { ...zuEintrag(datensatz), ausschnitt: t.ausschnitt }
})
.filter((e): e is Listeneintrag => e !== null)
const eintraege: Listeneintrag[] = []
for (const treffer of seitenTreffer) {
const datensatz = nachId.get(treffer.id)
if (!datensatz) continue
eintraege.push({ ...zuEintrag(datensatz), ausschnitt: treffer.ausschnitt })
}
return { eintraege, gesamt: treffer.length }
}
+338
View File
@@ -0,0 +1,338 @@
import 'server-only'
import type { DokumentVerknuepfung } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Verknuepfungsziel } from '@/lib/validierung/dokumente'
/**
* Woran laesst sich ein Dokument haengen?
*
* Hier stehen die Listen fuer das Auswahlfeld und die Beschriftungen der
* bestehenden Verknuepfungen. Alles ist auf die betreuteId eingegrenzt -
* ein Dokument kann niemals an eine fremde Akte gebunden werden.
*/
export type Zielauswahl = {
ziel: Verknuepfungsziel
eintraege: { id: string; titel: string }[]
}
const OBERGRENZE = 100
export async function zieleDerAkte(betreuteId: string): Promise<Zielauswahl[]> {
const [
vorgaenge,
fristen,
buchungen,
posten,
immobilien,
vertraege,
protokolle,
ereignisse,
genehmigungen,
bescheide,
nachforderungen,
kontakte,
] = await Promise.all([
db.vorgang.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, status: true },
orderBy: [{ status: 'asc' }, { erstelltAm: 'desc' }],
take: OBERGRENZE,
}),
db.frist.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, datum: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.buchung.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, datum: true, betragCent: true, gegenstelle: true, richtung: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.immobilie.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.kontaktprotokoll.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, datum: true, gespraechspartner: true, art: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.gesundheitsereignis.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, datum: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.genehmigung.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, rechtsgrundlage: true },
orderBy: { erstelltAm: 'desc' },
take: OBERGRENZE,
}),
db.bescheid.findMany({
where: { antrag: { betreuteId }, ...nichtGeloescht },
select: { id: true, art: true, bescheidVom: true },
orderBy: { bescheidVom: 'desc' },
take: OBERGRENZE,
}),
db.nachforderung.findMany({
where: { antrag: { betreuteId }, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { erstelltAm: 'desc' },
take: OBERGRENZE,
}),
db.kontakt.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: { name: 'asc' },
take: OBERGRENZE,
}),
])
const alle: Zielauswahl[] = [
{
ziel: 'vorgang',
eintraege: vorgaenge.map((v) => ({
id: v.id,
titel: v.status === 'ERLEDIGT' ? `${v.titel} (erledigt)` : v.titel,
})),
},
{
ziel: 'frist',
eintraege: fristen.map((f) => ({ id: f.id, titel: `${f.titel} – ${formatDatum(f.datum)}` })),
},
{
ziel: 'buchung',
eintraege: buchungen.map((b) => ({
id: b.id,
titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGANG' ? '−' : '+'}${formatCent(
b.betragCent,
)} · ${b.gegenstelle ?? 'ohne Gegenstelle'}`,
})),
},
{ ziel: 'vermoegensposten', eintraege: posten.map((p) => ({ id: p.id, titel: p.bezeichnung })) },
{ ziel: 'immobilie', eintraege: immobilien.map((i) => ({ id: i.id, titel: i.bezeichnung })) },
{
ziel: 'vertrag',
eintraege: vertraege.map((v) => ({
id: v.id,
titel: v.gesellschaft ? `${v.bezeichnung} – ${v.gesellschaft}` : v.bezeichnung,
})),
},
{
ziel: 'kontaktprotokoll',
eintraege: protokolle.map((p) => ({
id: p.id,
titel: `${formatDatum(p.datum)} · ${p.gespraechspartner ?? p.art}`,
})),
},
{
ziel: 'gesundheitsereignis',
eintraege: ereignisse.map((e) => ({ id: e.id, titel: `${formatDatum(e.datum)} · ${e.titel}` })),
},
{
ziel: 'genehmigung',
eintraege: genehmigungen.map((g) => ({ id: g.id, titel: `${g.titel} (${g.rechtsgrundlage})` })),
},
{
ziel: 'bescheid',
eintraege: bescheide.map((b) => ({ id: b.id, titel: `${b.art} vom ${formatDatum(b.bescheidVom)}` })),
},
{
ziel: 'nachforderung',
eintraege: nachforderungen.map((n) => ({ id: n.id, titel: n.bezeichnung })),
},
{
ziel: 'kontakt',
eintraege: kontakte.map((k) => ({
id: k.id,
titel: k.organisation ? `${k.name} – ${k.organisation}` : k.name,
})),
},
]
// Leere Kategorien gar nicht erst anbieten - ein Auswahlfeld ohne
// Eintraege ist nur eine Sackgasse.
return alle.filter((eintrag) => eintrag.eintraege.length > 0)
}
// -----------------------------------------------------------------------------
export type Verknuepfungsanzeige = {
id: string
ziel: Verknuepfungsziel
zielId: string
bezeichnung: string
bemerkung: string | null
href: string | null
}
type MitZielen = DokumentVerknuepfung & {
vorgang: { id: string; titel: string } | null
frist: { id: string; titel: string; datum: Date } | null
buchung: { id: string; datum: Date; betragCent: bigint; gegenstelle: string | null } | null
vermoegensposten: { id: string; bezeichnung: string } | null
immobilie: { id: string; bezeichnung: string } | null
vertrag: { id: string; bezeichnung: string } | null
kontaktprotokoll: { id: string; datum: Date; gespraechspartner: string | null } | null
gesundheitsereignis: { id: string; titel: string; datum: Date } | null
genehmigung: { id: string; titel: string } | null
nachforderung: { id: string; bezeichnung: string } | null
bescheid: { id: string; art: string; bescheidVom: Date } | null
kontakt: { id: string; name: string } | null
}
/** Die Auswahl, mit der eine Verknuepfung geladen werden muss. */
export const VERKNUEPFUNG_INCLUDE = {
vorgang: { select: { id: true, titel: true } },
frist: { select: { id: true, titel: true, datum: true } },
buchung: { select: { id: true, datum: true, betragCent: true, gegenstelle: true } },
vermoegensposten: { select: { id: true, bezeichnung: true } },
immobilie: { select: { id: true, bezeichnung: true } },
vertrag: { select: { id: true, bezeichnung: true } },
kontaktprotokoll: { select: { id: true, datum: true, gespraechspartner: true } },
gesundheitsereignis: { select: { id: true, titel: true, datum: true } },
genehmigung: { select: { id: true, titel: true } },
nachforderung: { select: { id: true, bezeichnung: true } },
bescheid: { select: { id: true, art: true, bescheidVom: true } },
kontakt: { select: { id: true, name: true } },
} as const
/** Macht aus einer Verknuepfungszeile eine anzeigbare Angabe. */
export function verknuepfungAnzeigen(zeile: MitZielen): Verknuepfungsanzeige | null {
const basis = { id: zeile.id, bemerkung: zeile.bemerkung }
if (zeile.vorgang) {
return {
...basis,
ziel: 'vorgang',
zielId: zeile.vorgang.id,
bezeichnung: zeile.vorgang.titel,
href: `/vorgaenge/${zeile.vorgang.id}`,
}
}
if (zeile.frist) {
return {
...basis,
ziel: 'frist',
zielId: zeile.frist.id,
bezeichnung: `${zeile.frist.titel} – ${formatDatum(zeile.frist.datum)}`,
href: `/fristen/${zeile.frist.id}`,
}
}
if (zeile.buchung) {
return {
...basis,
ziel: 'buchung',
zielId: zeile.buchung.id,
bezeichnung: `${formatDatum(zeile.buchung.datum)} · ${formatCent(zeile.buchung.betragCent)} · ${
zeile.buchung.gegenstelle ?? 'ohne Gegenstelle'
}`,
href: `/finanzen/buchungen/${zeile.buchung.id}`,
}
}
if (zeile.vermoegensposten) {
return {
...basis,
ziel: 'vermoegensposten',
zielId: zeile.vermoegensposten.id,
bezeichnung: zeile.vermoegensposten.bezeichnung,
href: `/finanzen/vermoegen/${zeile.vermoegensposten.id}`,
}
}
if (zeile.immobilie) {
return {
...basis,
ziel: 'immobilie',
zielId: zeile.immobilie.id,
bezeichnung: zeile.immobilie.bezeichnung,
href: `/immobilie/${zeile.immobilie.id}`,
}
}
if (zeile.vertrag) {
return {
...basis,
ziel: 'vertrag',
zielId: zeile.vertrag.id,
bezeichnung: zeile.vertrag.bezeichnung,
href: `/vertraege/${zeile.vertrag.id}`,
}
}
if (zeile.kontaktprotokoll) {
return {
...basis,
ziel: 'kontaktprotokoll',
zielId: zeile.kontaktprotokoll.id,
bezeichnung: `${formatDatum(zeile.kontaktprotokoll.datum)} · ${
zeile.kontaktprotokoll.gespraechspartner ?? 'Gesprächsnotiz'
}`,
href: `/kontakte/protokoll/${zeile.kontaktprotokoll.id}`,
}
}
if (zeile.gesundheitsereignis) {
return {
...basis,
ziel: 'gesundheitsereignis',
zielId: zeile.gesundheitsereignis.id,
bezeichnung: `${formatDatum(zeile.gesundheitsereignis.datum)} · ${zeile.gesundheitsereignis.titel}`,
href: `/pflege/${zeile.gesundheitsereignis.id}`,
}
}
if (zeile.genehmigung) {
return {
...basis,
ziel: 'genehmigung',
zielId: zeile.genehmigung.id,
bezeichnung: zeile.genehmigung.titel,
href: null,
}
}
if (zeile.nachforderung) {
return {
...basis,
ziel: 'nachforderung',
zielId: zeile.nachforderung.id,
bezeichnung: zeile.nachforderung.bezeichnung,
href: null,
}
}
if (zeile.bescheid) {
return {
...basis,
ziel: 'bescheid',
zielId: zeile.bescheid.id,
bezeichnung: `${zeile.bescheid.art} vom ${formatDatum(zeile.bescheid.bescheidVom)}`,
href: null,
}
}
if (zeile.kontakt) {
return {
...basis,
ziel: 'kontakt',
zielId: zeile.kontakt.id,
bezeichnung: zeile.kontakt.name,
href: `/kontakte/${zeile.kontakt.id}`,
}
}
return null
}
+19
View File
@@ -0,0 +1,19 @@
import 'server-only'
import { redirect } from 'next/navigation'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang, type ZugangsKontext } from '@/lib/auth/sitzung'
/**
* Einstieg fuer jede Seite des Finanzmoduls.
*
* Nimmt der Seite drei immer gleiche Handgriffe ab: angemeldeten
* Benutzer holen, die aktive betreute Person bestimmen und den Zugriff
* auf das Modul FINANZEN pruefen. Die Pruefung passiert hier serverseitig
* und noch einmal in jeder Server Action - eine Seite auszublenden ist
* kein Zugriffsschutz.
*/
export async function finanzKontext(): Promise<ZugangsKontext> {
const benutzer = await erfordereBenutzer('/finanzen')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
return erfordereZugang(aktive.id, 'FINANZEN', 'lesen')
}
+5
View File
@@ -26,6 +26,8 @@ export type WertstandEingabe = {
wertCent: bigint
wertquelle: string
nachweisDokumentId?: string | null
/** Titel des Nachweisdokuments - fuer den Belegverweis im Bericht. */
nachweisTitel?: string | null
bemerkung?: string | null
}
@@ -50,6 +52,7 @@ export type PostenZumStichtag = {
bewertetAm: Date
wertquelle: string
nachweisDokumentId: string | null
nachweisTitel: string | null
/** Wie alt ist der verwendete Wert am Stichtag? Null Tage = taggenau. */
alterTage: number
kontoId: string | null
@@ -136,6 +139,7 @@ export function vermoegenBerechnen(posten: PostenEingabe[], stichtag: Date): Ver
bewertetAm: alsTag(wert.stichtag),
wertquelle: wert.wertquelle,
nachweisDokumentId: wert.nachweisDokumentId ?? null,
nachweisTitel: wert.nachweisTitel ?? null,
alterTage: tageZwischen(wert.stichtag, tag),
kontoId: p.kontoId ?? null,
immobilieId: p.immobilieId ?? null,
@@ -205,6 +209,7 @@ export async function vermoegenZumStichtag(
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
nachweisTitel: null,
bemerkung: w.bemerkung,
})),
})),
+3 -2
View File
@@ -162,7 +162,8 @@ export function Berichtsseite({
mitUnterschrift?: boolean
/** Fuer breite Tabellen (Kassenbuch, Vermoegensuebersicht). */
querformat?: boolean
children: React.ReactNode
/** Optional: ein Deckblatt ohne Inhalt ist eine gueltige Seite. */
children?: React.ReactNode
}): React.JSX.Element {
const zeitraum = zeitraumText(kopf)
@@ -240,7 +241,7 @@ export function Berichtsdokument({
kopf: Berichtskopf
mitUnterschrift?: boolean
querformat?: boolean
children: React.ReactNode
children?: React.ReactNode
}): React.JSX.Element {
return (
<Document
+223
View File
@@ -0,0 +1,223 @@
import type { GesundheitsArt } from '@prisma/client'
/**
* Strukturierte Zusatzangaben der Schnellerfassung.
*
* Das Schema kennt am Gesundheitsereignis nur Titel, Datum, bisDatum,
* Behandler, Einrichtung, Abwesenheitstage und eine verschluesselte
* Beschreibung. Die Details, die bei einer COPD wirklich interessieren -
* Schweregrad einer Exazerbation, Sauerstofffluss, ob stationaer behandelt
* wurde - haben kein eigenes Feld (siehe docs/schema-wuensche/substanz.md).
*
* Sie werden deshalb als beschriftete Zeilen in die Beschreibung
* geschrieben, die ohnehin verschluesselt ist. Gesundheitsdaten sind
* besondere Kategorien nach Art. 9 DSGVO; genau deshalb landen sie dort
* und nicht in einem unverschluesselten Zusatzfeld.
*
* Das Format steht nur hier, damit es sich spaeter in echte Spalten
* migrieren laesst.
*/
export type Detailfeld = {
name: string
beschriftung: string
erklaerung?: string
typ: 'text' | 'zahl' | 'auswahl' | 'mehrzeilig' | 'jaNein'
optionen?: readonly string[]
einheit?: string
pflicht?: boolean
}
/**
* Welche Zusatzfelder gehoeren zu welcher Ereignisart? Bewusst wenige -
* eine Schnellerfassung, die zwanzig Felder zeigt, wird nicht benutzt,
* und dann steht am Jahresende gar nichts in der Akte.
*/
export const DETAILFELDER: Partial<Record<GesundheitsArt, readonly Detailfeld[]>> = {
EXAZERBATION: [
{
name: 'schweregrad',
beschriftung: 'Schweregrad',
typ: 'auswahl',
optionen: ['leicht', 'mittel', 'schwer'],
erklaerung:
'leicht = nur Bedarfsmedikation gesteigert, mittel = Antibiotikum oder Kortison, ' +
'schwer = Krankenhausbehandlung nötig.',
pflicht: true,
},
{
name: 'ausloeser',
beschriftung: 'Auslöser',
typ: 'text',
erklaerung: 'Zum Beispiel Atemwegsinfekt, Lungenentzündung, Kälte — oder „unklar“.',
},
{ name: 'behandlung', beschriftung: 'Behandlung', typ: 'mehrzeilig' },
{
name: 'stationaer',
beschriftung: 'Stationär behandelt',
typ: 'jaNein',
erklaerung:
'Wenn ja, leg zusätzlich einen Krankenhausaufenthalt an — nur dort werden ' +
'die Abwesenheitstage gezählt.',
},
],
SAUERSTOFFVERSORGUNG: [
{ name: 'fluss', beschriftung: 'Fluss', typ: 'zahl', einheit: 'l/min' },
{ name: 'stundenProTag', beschriftung: 'Anwendung', typ: 'zahl', einheit: 'Stunden/Tag' },
{
name: 'geraet',
beschriftung: 'Gerät',
typ: 'text',
erklaerung: 'Konzentrator, Flüssigsauerstoff, mobile Flasche — mit Hersteller und Modell.',
},
{ name: 'versorger', beschriftung: 'Versorger / Lieferant', typ: 'text' },
],
KRANKENHAUS: [
{ name: 'grund', beschriftung: 'Grund der Aufnahme', typ: 'mehrzeilig' },
{ name: 'station', beschriftung: 'Station', typ: 'text' },
{
name: 'entlassbericht',
beschriftung: 'Entlassbericht angefordert',
typ: 'jaNein',
erklaerung:
'Der Entlassbericht ist das wichtigste Papier für eine spätere ' +
'Höherstufung. Direkt anfordern, nicht darauf warten.',
},
],
MEDIKATIONSAENDERUNG: [
{ name: 'abgesetzt', beschriftung: 'Abgesetzt', typ: 'mehrzeilig' },
{ name: 'neu', beschriftung: 'Neu angesetzt', typ: 'mehrzeilig' },
{ name: 'veranlasstDurch', beschriftung: 'Veranlasst durch', typ: 'text' },
],
INHALATION: [
{ name: 'praeparat', beschriftung: 'Präparat', typ: 'text' },
{ name: 'haeufigkeit', beschriftung: 'Häufigkeit', typ: 'text' },
{
name: 'handhabung',
beschriftung: 'Handhabung durch die Betreute',
typ: 'auswahl',
optionen: ['selbstständig', 'mit Anleitung', 'durch Pflegekraft'],
},
],
STURZ: [
{ name: 'ort', beschriftung: 'Ort des Sturzes', typ: 'text' },
{ name: 'folgen', beschriftung: 'Verletzungen / Folgen', typ: 'mehrzeilig' },
{
name: 'sturzprotokoll',
beschriftung: 'Sturzprotokoll des Heims vorhanden',
typ: 'jaNein',
erklaerung:
'Die Einrichtung muss jeden Sturz protokollieren. Lass dir das Protokoll ' +
'geben und lege es zur Akte.',
},
],
HILFSMITTEL: [
{ name: 'hilfsmittel', beschriftung: 'Hilfsmittel', typ: 'text', pflicht: true },
{
name: 'kostentraeger',
beschriftung: 'Kostenträger',
typ: 'auswahl',
optionen: ['Krankenkasse', 'Pflegekasse', 'selbst gezahlt', 'Leihgabe', 'unklar'],
},
{ name: 'verordnetVon', beschriftung: 'Verordnet von', typ: 'text' },
],
IMPFUNG: [
{ name: 'impfstoff', beschriftung: 'Impfstoff', typ: 'text' },
{ name: 'chargennummer', beschriftung: 'Chargennummer', typ: 'text' },
{
name: 'einwilligung',
beschriftung: 'Einwilligung',
typ: 'auswahl',
optionen: [
'durch die Betreute selbst',
'durch mich nach ärztlicher Aufklärung',
'nach Patientenverfügung',
],
erklaerung:
'Wer eingewilligt hat, gehört dokumentiert — das ist eine Frage von ' +
'§ 1821 und § 1829 BGB, nicht nur eine Formalie.',
},
],
PFLEGEBEGUTACHTUNG: [
{ name: 'ergebnisGrad', beschriftung: 'Festgestellter Pflegegrad', typ: 'zahl' },
{ name: 'gutachter', beschriftung: 'Gutachter / Dienst', typ: 'text' },
{
name: 'ergebnis',
beschriftung: 'Ergebnis',
typ: 'auswahl',
optionen: ['hochgestuft', 'unverändert', 'abgelehnt', 'herabgestuft'],
},
{ name: 'widerspruchsfrist', beschriftung: 'Widerspruch bis', typ: 'text' },
],
}
export function detailfelderFuer(art: GesundheitsArt): readonly Detailfeld[] {
return DETAILFELDER[art] ?? []
}
/**
* Baut den Beschreibungstext aus den Zusatzfeldern und dem Freitext.
* Erst die beschrifteten Zeilen, dann eine Leerzeile, dann der Freitext -
* so bleibt beides beim Wiederlesen unterscheidbar.
*/
export function detailsFormatieren(
art: GesundheitsArt,
werte: Record<string, string | null | undefined>,
freitext?: string | null,
): string | null {
const zeilen: string[] = []
for (const feld of detailfelderFuer(art)) {
const wert = werte[feld.name]
if (wert === null || wert === undefined || wert === '') continue
const einheit = feld.einheit ? ` ${feld.einheit}` : ''
zeilen.push(`${feld.beschriftung}: ${wert}${einheit}`)
}
const freitextSauber = freitext?.trim()
if (freitextSauber) {
if (zeilen.length > 0) zeilen.push('')
zeilen.push(freitextSauber)
}
return zeilen.length > 0 ? zeilen.join('\n') : null
}
/**
* Liest den Text wieder auseinander - fuer das Bearbeitungsformular.
* Was sich keinem Feld zuordnen laesst, bleibt Freitext.
*/
export function detailsLesen(
art: GesundheitsArt,
beschreibung: string | null | undefined,
): { werte: Record<string, string>; freitext: string } {
const werte: Record<string, string> = {}
const freitext: string[] = []
if (!beschreibung) return { werte, freitext: '' }
const felder = detailfelderFuer(art)
let imFreitext = false
for (const zeile of beschreibung.split('\n')) {
if (imFreitext) {
freitext.push(zeile)
continue
}
if (zeile.trim() === '') {
imFreitext = true
continue
}
const treffer = felder.find((f) => zeile.startsWith(`${f.beschriftung}: `))
if (treffer) {
let wert = zeile.slice(treffer.beschriftung.length + 2).trim()
if (treffer.einheit && wert.endsWith(treffer.einheit)) {
wert = wert.slice(0, -treffer.einheit.length).trim()
}
werte[treffer.name] = wert
} else {
freitext.push(zeile)
}
}
return { werte, freitext: freitext.join('\n').trim() }
}
+173
View File
@@ -0,0 +1,173 @@
import { heuteAlsTag, plusJahre, plusMonate, tag, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Impfungen bei COPD.
*
* Warum das hier steht und nicht nur beim Hausarzt: Bei einer
* fortgeschrittenen COPD ist jede Atemwegsinfektion ein moeglicher
* Ausloeser fuer eine Exazerbation - und jede Exazerbation kostet
* Lungenfunktion, die nicht zurueckkommt. Die STIKO empfiehlt bei
* chronischen Atemwegserkrankungen ausdruecklich Grippe-, Pneumokokken-,
* COVID- und RSV-Impfung. Im Heim geht das schnell unter, weil sich
* niemand zustaendig fuehlt.
*
* Diese Datei rechnet nur Faelligkeiten aus. Sie entscheidet nichts: Ob
* geimpft wird, entscheidet die betreute Person - oder, wenn sie es nicht
* mehr kann, der Betreuer nach ihrem mutmasslichen Willen (§ 1821 BGB),
* und zwar nach aerztlicher Aufklaerung.
*/
/** Stand der zugrunde liegenden STIKO-Empfehlungen. */
export const IMPFPLAN_STAND = '01.01.2025'
export type Impfschluessel = 'grippe' | 'pneumokokken' | 'covid' | 'rsv'
export type Impfempfehlung = {
schluessel: Impfschluessel
bezeichnung: string
/** Abstand bis zur naechsten Impfung in Monaten; null heisst einmalig. */
intervallMonate: number | null
/** Saisonale Impfungen: bevorzugter Monat. */
saisonMonat?: number
begruendung: string
}
export const IMPFPLAN_COPD: readonly Impfempfehlung[] = [
{
schluessel: 'grippe',
bezeichnung: 'Grippe (Influenza)',
intervallMonate: 12,
saisonMonat: 10,
begruendung:
'Jährlich, weil sich der Impfstoff jede Saison ändert. Für Menschen ab 60 ' +
'empfiehlt die STIKO den Hochdosis-Impfstoff. Bester Zeitpunkt: Oktober bis ' +
'Mitte Dezember, vor der Welle.',
},
{
schluessel: 'pneumokokken',
bezeichnung: 'Pneumokokken',
intervallMonate: null,
begruendung:
'Pneumokokken sind der häufigste Erreger der bakteriellen Lungenentzündung — ' +
'bei COPD die gefürchtetste Komplikation. Bei chronischer Atemwegserkrankung ' +
'empfohlen; eine Wiederholung nach frühestens sechs Jahren wird ärztlich ' +
'entschieden.',
},
{
schluessel: 'covid',
bezeichnung: 'COVID-19 (Auffrischung)',
intervallMonate: 12,
saisonMonat: 10,
begruendung:
'Für Menschen ab 60 und bei Grunderkrankung empfiehlt die STIKO eine jährliche ' +
'Auffrischung im Herbst, mit mindestens zwölf Monaten Abstand zur letzten ' +
'Impfung oder Infektion.',
},
{
schluessel: 'rsv',
bezeichnung: 'RSV (Respiratorisches Synzytial-Virus)',
intervallMonate: null,
saisonMonat: 9,
begruendung:
'Seit 2024 für alle ab 75 und ab 60 bei Grunderkrankung wie COPD empfohlen. ' +
'Einmalige Impfung, am besten im Spätsommer oder Herbst vor der Saison.',
},
] as const
export type Impfstatus = {
empfehlung: Impfempfehlung
letzteImpfung: Date | null
/** Wann wäre die nächste fällig? Null, wenn einmalig und erledigt. */
faelligAm: Date | null
/** Negativ heißt: überfällig. */
tageBisFaellig: number | null
lage: 'unbekannt' | 'faellig' | 'bald' | 'erledigt'
text: string
}
/**
* Erkennt anhand des Titels, um welche Impfung es ging. Der Titel steht
* unverschluesselt am Ereignis - eine Impfbezeichnung ist keine Diagnose.
*/
export function impfungErkennen(titel: string): Impfschluessel | null {
const text = titel.toLowerCase()
if (text.includes('grippe') || text.includes('influenza')) return 'grippe'
if (text.includes('pneumokokk')) return 'pneumokokken'
if (text.includes('covid') || text.includes('corona')) return 'covid'
if (text.includes('rsv')) return 'rsv'
return null
}
export function impfstatusBerechnen(
impfungen: { datum: Date; titel: string }[],
heute: Date = heuteAlsTag(),
): Impfstatus[] {
const stichtag = tagesdatum(heute)
const letzte = new Map<Impfschluessel, Date>()
for (const impfung of impfungen) {
const schluessel = impfungErkennen(impfung.titel)
if (!schluessel) continue
const datum = tagesdatum(impfung.datum)
const vorhanden = letzte.get(schluessel)
if (!vorhanden || datum.getTime() > vorhanden.getTime()) letzte.set(schluessel, datum)
}
return IMPFPLAN_COPD.map((empfehlung) => {
const letzteImpfung = letzte.get(empfehlung.schluessel) ?? null
if (!letzteImpfung) {
return {
empfehlung,
letzteImpfung: null,
faelligAm: null,
tageBisFaellig: null,
lage: 'unbekannt',
text: 'Nicht dokumentiert. Beim Hausarzt oder im Heim nach dem Impfpass fragen.',
}
}
if (empfehlung.intervallMonate === null) {
return {
empfehlung,
letzteImpfung,
faelligAm: null,
tageBisFaellig: null,
lage: 'erledigt',
text: 'Einmalige Impfung, dokumentiert. Eine Auffrischung entscheidet der Arzt.',
}
}
let faellig = plusMonate(letzteImpfung, empfehlung.intervallMonate)
// Saisonale Impfungen rutschen auf den Saisonmonat, wenn dieser
// spaeter im selben Jahr liegt - eine Grippeimpfung im Maerz nuetzt
// wenig.
if (empfehlung.saisonMonat) {
const saison = tag(faellig.getUTCFullYear(), empfehlung.saisonMonat, 1)
if (saison.getTime() > faellig.getTime()) faellig = saison
}
const tage = tageZwischen(stichtag, faellig)
const lage: Impfstatus['lage'] = tage <= 0 ? 'faellig' : tage <= 60 ? 'bald' : 'erledigt'
const text =
lage === 'faellig'
? `Seit ${Math.abs(tage)} Tagen fällig.`
: lage === 'bald'
? `In ${tage} Tagen fällig.`
: `Aktuell. Nächste Impfung in ${tage} Tagen.`
return { empfehlung, letzteImpfung, faelligAm: faellig, tageBisFaellig: tage, lage, text }
})
}
/** Nur die Impfungen, bei denen etwas zu tun ist - fuer die Kachel. */
export function offeneImpfungen(status: Impfstatus[]): Impfstatus[] {
return status.filter((s) => s.lage === 'faellig' || s.lage === 'unbekannt' || s.lage === 'bald')
}
/** Ein Jahr nach der letzten Grippeimpfung - fuer die Wiedervorlage. */
export function naechsteGrippesaison(heute: Date = heuteAlsTag()): Date {
const stichtag = tagesdatum(heute)
const diesesJahr = tag(stichtag.getUTCFullYear(), 10, 1)
return diesesJahr.getTime() >= stichtag.getTime() ? diesesJahr : plusJahre(diesesJahr, 1)
}
+61
View File
@@ -0,0 +1,61 @@
import type { VermoegensArt, VertragArt } from '@prisma/client'
/**
* Menschenlesbare Namen der Vermögens- und Vertragsarten.
*
* Sie stehen hier, weil die Bewertungsansicht sie braucht und die Module
* "Vermögen & Kasse" und "Verträge" ihre eigenen Beschriftungen führen.
* Sobald dort eine gemeinsame Quelle existiert, wandert diese Datei
* dorthin - bis dahin lieber eine kleine Doppelung als ein Modul, das auf
* ein anderes wartet.
*/
export const VERMOEGENSART_BESCHRIFTUNG: Record<VermoegensArt, string> = {
KONTO: 'Konto',
IMMOBILIE: 'Immobilie',
VERSICHERUNG: 'Versicherung',
FAHRZEUG: 'Fahrzeug',
WERTGEGENSTAND: 'Wertgegenstand',
FORDERUNG: 'Forderung',
VERBINDLICHKEIT: 'Verbindlichkeit',
SONSTIGES: 'Sonstiges',
}
export const VERTRAGSART_BESCHRIFTUNG: Record<VertragArt, string> = {
LEBENSVERSICHERUNG: 'Lebensversicherung',
STERBEGELDVERSICHERUNG: 'Sterbegeldversicherung',
BESTATTUNGSVORSORGE: 'Bestattungsvorsorge',
RENTENVERSICHERUNG_PRIVAT: 'Private Rentenversicherung',
UNFALLVERSICHERUNG: 'Unfallversicherung',
HAFTPFLICHT: 'Haftpflichtversicherung',
HAUSRAT: 'Hausratversicherung',
WOHNGEBAEUDE: 'Wohngebäudeversicherung',
KRANKENZUSATZ: 'Krankenzusatzversicherung',
PFLEGEZUSATZ: 'Pflegezusatzversicherung',
RECHTSSCHUTZ: 'Rechtsschutzversicherung',
STERBEKASSE: 'Sterbekasse',
STROM: 'Stromvertrag',
GAS: 'Gasvertrag',
WASSER: 'Wasserversorgung',
TELEFON_INTERNET: 'Telefon und Internet',
RUNDFUNKBEITRAG: 'Rundfunkbeitrag',
MITGLIEDSCHAFT: 'Mitgliedschaft',
ABONNEMENT: 'Abonnement',
SONSTIGES: 'Sonstiges',
}
/**
* Vertragsarten, die im Sozialhilfeverfahren überhaupt Vermögen darstellen
* können. Ein Stromvertrag hat keinen Rückkaufswert - er gehört nicht in
* die Bewertungsansicht und würde sie nur zumüllen.
*/
export const VERMOEGENSRELEVANTE_VERTRAGSARTEN: readonly VertragArt[] = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'PFLEGEZUSATZ',
'KRANKENZUSATZ',
'STERBEKASSE',
'UNFALLVERSICHERUNG',
] as const
+140
View File
@@ -0,0 +1,140 @@
import type { BescheidArt, BescheidPruefStatus } from '@prisma/client'
/**
* Beschriftungen rund um Bescheide.
*
* Getrennt von rechtsbehelf.ts, weil dort gerechnet wird und hier nur
* benannt. So bleibt die Fristberechnung frei von Anzeigetexten und
* lässt sich ohne React testen.
*/
export const BESCHEID_ARTEN: readonly BescheidArt[] = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const BESCHEID_ART_BESCHRIFTUNG: Record<BescheidArt, string> = {
BEWILLIGUNG: 'Bewilligung',
ABLEHNUNG: 'Ablehnung',
AENDERUNG: 'Änderung',
AUFHEBUNG: 'Aufhebung',
ERSTATTUNG: 'Erstattung / Rückforderung',
VORSCHUSS: 'Vorschuss',
WIDERSPRUCHSBESCHEID: 'Widerspruchsbescheid',
}
export const BESCHEID_ART_ERKLAERUNG: Record<BescheidArt, string> = {
BEWILLIGUNG:
'Der Träger bewilligt die Leistung. Trotzdem lesen: Bewilligungszeitraum, Höhe und ' +
'angerechnetes Einkommen können unrichtig sein, und auch dagegen läuft die Widerspruchsfrist.',
ABLEHNUNG:
'Die Leistung wird versagt. Hier entscheidet der Widerspruch — die Begründung genau lesen ' +
'und dagegenhalten, wo die Einschätzung zum Vermögen abweicht.',
AENDERUNG:
'Eine bestehende Bewilligung wird geändert, meist wegen geänderter Renten oder Heimentgelte. ' +
'Die Änderung zu Ungunsten für die Vergangenheit ist nur unter den Voraussetzungen der ' +
'§§ 45, 48 SGB X zulässig.',
AUFHEBUNG:
'Die Bewilligung wird aufgehoben. Prüfen, ob die Voraussetzungen der §§ 45 bis 48 SGB X ' +
'wirklich vorlagen und ob Vertrauensschutz besteht.',
ERSTATTUNG:
'Der Träger fordert bereits gezahlte Leistungen zurück (§ 50 SGB X). Fast immer verbunden ' +
'mit einer Aufhebung — beide Bescheide gesondert prüfen.',
VORSCHUSS:
'Vorläufige Zahlung, bis die endgültige Entscheidung feststeht (§ 42 SGB I). Der Vorschuss ' +
'wird später verrechnet.',
WIDERSPRUCHSBESCHEID:
'Entscheidung über den Widerspruch. Ab Zugang läuft die Klagefrist zum Sozialgericht — ' +
'ein Monat (§ 87 SGG). Die Klage ist gerichtskostenfrei (§ 183 SGG).',
}
export const PRUEFSTATUS: readonly BescheidPruefStatus[] = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const PRUEFSTATUS_BESCHRIFTUNG: Record<BescheidPruefStatus, string> = {
UNGEPRUEFT: 'Ungeprüft',
GEPRUEFT_OK: 'Geprüft, in Ordnung',
WIDERSPRUCH_EINGELEGT: 'Widerspruch eingelegt',
WIDERSPRUCH_ERLEDIGT: 'Widerspruch erledigt',
FRIST_VERSTRICHEN: 'Frist verstrichen',
}
export const PRUEFSTATUS_ERKLAERUNG: Record<BescheidPruefStatus, string> = {
UNGEPRUEFT:
'Noch nicht durchgesehen. Solange hier "ungeprüft" steht, läuft die Widerspruchsfrist ' +
'ungenutzt weiter.',
GEPRUEFT_OK: 'Durchgesehen, kein Widerspruch nötig. Die Entscheidung ist damit hingenommen.',
WIDERSPRUCH_EINGELEGT:
'Der Widerspruch ist raus. Der Träger muss abhelfen oder die Sache der Widerspruchsstelle ' +
'vorlegen (§ 85 SGG). Passiert drei Monate nichts, ist die Untätigkeitsklage möglich ' +
'(§ 88 SGG).',
WIDERSPRUCH_ERLEDIGT:
'Das Widerspruchsverfahren ist abgeschlossen — durch Abhilfe, Widerspruchsbescheid oder ' +
'Rücknahme.',
FRIST_VERSTRICHEN:
'Die Widerspruchsfrist ist abgelaufen, ohne dass Widerspruch eingelegt wurde. Der Bescheid ' +
'ist bestandskräftig. Ein Überprüfungsantrag nach § 44 SGB X bleibt möglich, wenn der ' +
'Bescheid von Anfang an rechtswidrig war — das ist kein Ersatz für den Widerspruch, aber ' +
'auch keine verschlossene Tür.',
}
export const PRUEFSTATUS_VARIANTE: Record<
BescheidPruefStatus,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
UNGEPRUEFT: 'rot',
GEPRUEFT_OK: 'gruen',
WIDERSPRUCH_EINGELEGT: 'orange',
WIDERSPRUCH_ERLEDIGT: 'gruen',
FRIST_VERSTRICHEN: 'secondary',
}
/**
* Welche Statuswechsel sind sinnvoll?
*
* Bewusst keine harte Sperre - wer sich vertippt hat, muss zurück können.
* Die Oberfläche schlägt aber nur die passenden Ziele vor.
*/
export const PRUEFSTATUS_UEBERGAENGE: Record<BescheidPruefStatus, readonly BescheidPruefStatus[]> = {
UNGEPRUEFT: ['GEPRUEFT_OK', 'WIDERSPRUCH_EINGELEGT', 'FRIST_VERSTRICHEN'],
GEPRUEFT_OK: ['UNGEPRUEFT', 'WIDERSPRUCH_EINGELEGT'],
WIDERSPRUCH_EINGELEGT: ['WIDERSPRUCH_ERLEDIGT', 'UNGEPRUEFT'],
WIDERSPRUCH_ERLEDIGT: ['WIDERSPRUCH_EINGELEGT'],
FRIST_VERSTRICHEN: ['UNGEPRUEFT', 'GEPRUEFT_OK'],
}
/**
* Was ist bei diesem Bescheid als Nächstes zu tun? Ein Satz, der auf der
* Übersicht steht - damit nicht jeder Bescheid einzeln geöffnet werden muss.
*/
export function naechsterSchritt(
pruefStatus: BescheidPruefStatus,
tageVerbleibend: number | null,
): string {
switch (pruefStatus) {
case 'UNGEPRUEFT':
if (tageVerbleibend === null) return 'Zugangsdatum eintragen, dann prüfen.'
if (tageVerbleibend < 0)
return 'Frist abgelaufen. Prüfen, ob ein Überprüfungsantrag nach § 44 SGB X in Betracht kommt.'
if (tageVerbleibend <= 7) return `Dringend prüfen — nur noch ${tageVerbleibend} Tage.`
return `Prüfen. Noch ${tageVerbleibend} Tage für den Widerspruch.`
case 'GEPRUEFT_OK':
return 'Nichts zu tun. Bescheid zu den Akten.'
case 'WIDERSPRUCH_EINGELEGT':
return 'Auf die Entscheidung des Trägers warten. Nach drei Monaten ohne Antwort: § 88 SGG.'
case 'WIDERSPRUCH_ERLEDIGT':
return 'Abgeschlossen. Bei einem Widerspruchsbescheid läuft die Klagefrist von einem Monat.'
case 'FRIST_VERSTRICHEN':
return 'Bestandskräftig. Nur noch Überprüfungsantrag nach § 44 SGB X möglich.'
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Eingelesene Beträge in Cent umrechnen.
*
* Die Anwendung rechnet ausschließlich mit BigInt-Cent. Der Weg vom
* Formularfeld dorthin ist die einzige Stelle, an der eine Zeichenkette in
* eine Zahl wird - deshalb steht er hier und nicht verstreut in den
* Server Actions.
*
* Bewusst NICHT über parseFloat: 1234,56 als Fließkomma und dann mal 100
* ergibt 123455.99999999999. In der Rechnungslegung gegenüber dem Gericht
* hat ein solcher Cent nichts verloren.
*/
/** "1.234,56", "1234,56", "1234.56", "1234" -> 123456n. */
export function betragZuCent(eingabe: string | null | undefined): bigint | null {
if (eingabe === null || eingabe === undefined) return null
let text = String(eingabe).trim()
if (text === '') return null
text = text.replace(/\s/g, '').replace(/€/g, '').replace(/EUR/gi, '')
let negativ = false
if (text.startsWith('-')) {
negativ = true
text = text.slice(1)
} else if (text.startsWith('+')) {
text = text.slice(1)
}
// Deutsche Schreibweise: Punkt trennt Tausender, Komma die Cent.
// Englische Schreibweise mit Punkt als Dezimaltrenner wird ebenfalls
// angenommen, solange sie eindeutig ist (genau ein Punkt, ein bis zwei
// Nachkommastellen, kein Komma).
if (text.includes(',')) {
text = text.replace(/\./g, '').replace(',', '.')
} else {
const punkte = text.split('.').length - 1
if (punkte > 1) {
text = text.replace(/\./g, '')
} else if (punkte === 1) {
const nachkomma = text.split('.')[1] ?? ''
// "1.234" ist ein Tausenderpunkt, "1.23" ein Dezimalpunkt.
if (nachkomma.length === 3) text = text.replace('.', '')
}
}
const treffer = /^(\d+)(?:\.(\d{1,2}))?$/.exec(text)
if (!treffer) return null
const euro = BigInt(treffer[1]!)
const centText = (treffer[2] ?? '').padEnd(2, '0')
const cent = BigInt(centText)
const gesamt = euro * 100n + cent
return negativ ? -gesamt : gesamt
}
/** Gegenrichtung: 123456n -> "1234,56" für die Vorbelegung von Feldern. */
export function centZuEingabe(cent: bigint | null | undefined): string {
if (cent === null || cent === undefined) return ''
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const euro = betrag / 100n
const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
}
+36
View File
@@ -0,0 +1,36 @@
import 'server-only'
import { redirect } from 'next/navigation'
import type { Betreute, User } from '@prisma/client'
import type { Zugriffsart } from '@/lib/auth/rollen'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
/**
* Der immer gleiche Vorspann jeder Seite und jeder Server Action im
* Sozialhilfemodul: angemeldeter Benutzer, aktive betreute Person,
* geprüfter Zugriff auf das Modul SOZIALHILFE.
*
* Ohne diesen Helfer schleicht sich früher oder später eine Seite ein, die
* die Prüfung vergisst. Der Zugriffsschutz sitzt serverseitig und gilt
* auch für die Aktionen, nicht nur für die Anzeige.
*/
export type SozialhilfeKontext = {
benutzer: User
betreute: Betreute
betreuteId: string
darfSchreiben: boolean
}
export async function sozialhilfeKontext(art: Zugriffsart = 'lesen'): Promise<SozialhilfeKontext> {
const benutzer = await erfordereBenutzer('/sozialhilfe')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { betreute, darfSchreiben } = await erfordereZugang(aktive.id, 'SOZIALHILFE', art)
return {
benutzer,
betreute,
betreuteId: betreute.id,
darfSchreiben: darfSchreiben('SOZIALHILFE'),
}
}
+286
View File
@@ -0,0 +1,286 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Berichts- und Exportfunktionen - und die
* Namensgebung fuer Dateien im Vollexport.
*
* Warum stehen die Dateinamenshelfer hier und nicht in vollexport.ts?
* Weil sie reine Funktionen sind, die ohne Datenbank, ohne Archivierer und
* ohne Objektspeicher getestet werden koennen - und weil ein Fehler an
* dieser Stelle teuer ist: Aus einem Dokumenttitel wie
* "Bescheid ../../etc/passwd" darf im ZIP niemals ein Pfad ausserhalb des
* Zielordners werden.
*/
/** cuid/uuid-artige Kennungen - nichts, was in einem Pfad gefaehrlich waere. */
export const kennung = z
.string()
.trim()
.regex(/^[A-Za-z0-9_-]{1,64}$/, 'Ungültige Kennung.')
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
export const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Bitte ein Datum im Format TT.MM.JJJJ wählen.')
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
export const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
// -----------------------------------------------------------------------------
// Schemata
// -----------------------------------------------------------------------------
export const jahresberichtErzeugenSchema = z
.object({
betreuteId: kennung,
zeitraumVon: pflichtDatum,
zeitraumBis: pflichtDatum,
})
.refine((wert) => wert.zeitraumVon.getTime() <= wert.zeitraumBis.getTime(), {
path: ['zeitraumBis'],
message: 'Das Ende des Berichtszeitraums muss nach seinem Beginn liegen.',
})
export const entwurfSpeichernSchema = z.object({
berichtId: kennung,
entwurf: z
.string()
.max(200_000, 'Der Entwurf ist zu lang. Bitte kürze ihn oder lade Anlagen separat hoch.'),
})
export const berichtAbgabeSchema = z.object({
berichtId: kennung,
abgegebenAm: pflichtDatum,
abgegebenAn: z.string().trim().min(2, 'Bitte angeben, wem der Bericht vorgelegt wurde.').max(200),
})
export const belegmappeSchema = z
.object({
betreuteId: kennung,
quelle: z.enum(['zeitraum', 'vorgang'], {
errorMap: () => ({ message: 'Bitte Zeitraum oder Vorgang wählen.' }),
}),
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
vorgangId: z.string().trim().optional(),
titel: z.string().trim().max(200).optional(),
})
.superRefine((wert, kontext) => {
if (wert.quelle === 'zeitraum') {
if (!wert.zeitraumVon || !wert.zeitraumBis) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['zeitraumVon'],
message: 'Für eine Belegmappe über einen Zeitraum brauchst du Beginn und Ende.',
})
return
}
if (wert.zeitraumVon.getTime() > wert.zeitraumBis.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['zeitraumBis'],
message: 'Das Ende muss nach dem Beginn liegen.',
})
}
} else if (!wert.vorgangId || !/^[A-Za-z0-9_-]{1,64}$/.test(wert.vorgangId)) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['vorgangId'],
message: 'Bitte einen Vorgang auswählen.',
})
}
})
export const unterlagenpaketSchema = z.object({
betreuteId: kennung,
antragId: kennung,
nachforderungIds: z
.array(kennung)
.min(1, 'Bitte mindestens eine Unterlage auswählen, die mitgeschickt werden soll.'),
anschreibenZusatz: z.string().trim().max(4000).optional().default(''),
})
export const vollexportSchema = z.object({
betreuteId: kennung,
mitDokumenten: z.boolean().default(true),
mitAuditLog: z.boolean().default(true),
mitBerichten: z.boolean().default(true),
})
export type JahresberichtErzeugenEingabe = z.infer<typeof jahresberichtErzeugenSchema>
export type EntwurfSpeichernEingabe = z.infer<typeof entwurfSpeichernSchema>
export type BelegmappeEingabe = z.infer<typeof belegmappeSchema>
export type UnterlagenpaketEingabe = z.infer<typeof unterlagenpaketSchema>
export type VollexportEingabe = z.infer<typeof vollexportSchema>
// -----------------------------------------------------------------------------
// Dateinamen im Export
// -----------------------------------------------------------------------------
const UMSCHRIFT: Record<string, string> = {
ä: 'ae',
ö: 'oe',
ü: 'ue',
Ä: 'Ae',
Ö: 'Oe',
Ü: 'Ue',
ß: 'ss',
é: 'e',
è: 'e',
ê: 'e',
á: 'a',
à: 'a',
â: 'a',
ó: 'o',
ò: 'o',
ô: 'o',
ú: 'u',
ù: 'u',
û: 'u',
ñ: 'n',
ç: 'c',
}
/** Namen, die Windows nie als Datei akzeptiert - egal mit welcher Endung. */
const RESERVIERT = new Set([
'con',
'prn',
'aux',
'nul',
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
])
export const DATEINAME_MAXLAENGE = 80
/**
* Macht aus beliebigem Text ein Stueck Dateiname, das in einem ZIP, unter
* Windows, unter macOS und unter Linux funktioniert.
*
* Erlaubt bleiben ausschliesslich Buchstaben ohne Akzent, Ziffern,
* Bindestrich und Unterstrich. Alles andere - Schraegstriche, Punkte,
* Doppelpunkte, Steuerzeichen, Umlaute, Leerzeichen - wird umgeschrieben
* oder ersetzt. Damit kann das Ergebnis weder ein Verzeichnis wechseln
* noch einen Datenstrom (":stream") oeffnen.
*/
export function sichererDateiname(roh: string, ersatz = 'ohne-titel'): string {
const umgeschrieben = [...(roh ?? '')]
.map((zeichen) => UMSCHRIFT[zeichen] ?? zeichen)
.join('')
// Kombinierende Akzente aus zusammengesetzten Zeichen entfernen.
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
const gesaeubert = umgeschrieben
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, DATEINAME_MAXLAENGE)
.replace(/^[-_]+|[-_]+$/g, '')
if (gesaeubert === '') return ersatz
if (RESERVIERT.has(gesaeubert.toLowerCase())) return `_${gesaeubert}`
return gesaeubert
}
/** Endung ohne Punkt, klein, hoechstens fuenf Zeichen - sonst "bin". */
export function sichereEndung(kandidat: string | null | undefined, ersatz = 'bin'): string {
const roh = (kandidat ?? '').toLowerCase()
const letzter = roh.includes('.') ? roh.slice(roh.lastIndexOf('.') + 1) : roh
const sauber = letzter.replace(/[^a-z0-9]/g, '')
if (sauber === '' || sauber.length > 5) return ersatz
return sauber
}
/** "2026-03-14" - der Datumsteil im Dateinamen, sortierbar. */
export function isoTag(datum: Date | null | undefined, ersatz = 'ohne-datum'): string {
if (!datum || Number.isNaN(datum.getTime())) return ersatz
return datum.toISOString().slice(0, 10)
}
export type ExportDokument = {
datum: Date | null
absender: string | null
titel: string
dateiname: string | null
mimeTyp: string | null
}
const MIME_ENDUNG: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/heic': 'heic',
'image/webp': 'webp',
'image/tiff': 'tif',
}
/**
* Der Pfad eines Dokuments im Vollexport:
*
* dokumente/<jahr>/<datum>_<absender>_<titel>.<endung>
*
* Ohne bekanntes Datum landet die Datei unter "ohne-jahr" - besser als ein
* erfundenes Jahr, in dem sie spaeter niemand sucht.
*/
export function exportDokumentPfad(dokument: ExportDokument): string {
const jahr = dokument.datum && !Number.isNaN(dokument.datum.getTime())
? String(dokument.datum.getUTCFullYear())
: 'ohne-jahr'
const teile = [
isoTag(dokument.datum),
sichererDateiname(dokument.absender ?? '', 'ohne-absender'),
sichererDateiname(dokument.titel, 'ohne-titel'),
]
const endung = sichereEndung(
dokument.dateiname ?? MIME_ENDUNG[dokument.mimeTyp ?? ''] ?? '',
MIME_ENDUNG[dokument.mimeTyp ?? ''] ?? 'bin',
)
return `dokumente/${jahr}/${teile.join('_')}.${endung}`
}
/**
* Letzte Sicherung vor dem Schreiben ins Archiv: Ein Pfad darf nur aus
* harmlosen Segmenten bestehen, nie absolut sein und nie ".." enthalten.
* Wird fuer JEDEN Eintrag im ZIP aufgerufen, auch fuer die selbst
* erzeugten - eine Regel, die nur manchmal gilt, gilt nicht.
*/
export function pfadIstSicher(pfad: string): boolean {
if (typeof pfad !== 'string' || pfad === '' || pfad.length > 400) return false
if (pfad.includes('\\') || pfad.includes('\0') || pfad.startsWith('/')) return false
if (/^[A-Za-z]:/.test(pfad)) return false
const segmente = pfad.split('/')
return segmente.every((teil) => teil !== '' && teil !== '.' && teil !== '..' && !/[<>:"|?*]/.test(teil))
}
/** Wie pfadIstSicher, wirft aber - fuer den Einsatz im Exportlauf. */
export function pfadErfordern(pfad: string): string {
if (!pfadIstSicher(pfad)) {
throw new Error(`Der Pfad "${pfad}" ist für ein Archiv nicht zulässig.`)
}
return pfad
}
/** Dateiname eines erzeugten Berichts: "2026-08-22_jahresbericht_musterfrau.pdf". */
export function berichtDateiname(
art: string,
nachname: string,
stichtag: Date,
endung: 'pdf' | 'docx' | 'zip',
): string {
return [
isoTag(stichtag),
sichererDateiname(art, 'bericht'),
sichererDateiname(nachname, 'betreute'),
].join('_').concat(`.${endung}`)
}
+315
View File
@@ -0,0 +1,315 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Dokumentenakte.
*
* Geprueft wird IMMER serverseitig - das Formular im Browser ist nur
* Bequemlichkeit. Alle Meldungen sagen, was zu tun ist, nicht was falsch
* war.
*
* Was hier bewusst NICHT steht: die Pruefung der Datei selbst. Groesse und
* Dateityp entscheidet `src/lib/speicher` an den Magic Bytes - ein Zod-
* Schema koennte dazu nur wiederholen, was der Browser behauptet.
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.optional()
.transform((wert) => wert ?? null)
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
const optionalesDatum = z
.string()
.trim()
.nullable()
.optional()
.transform((wert) => (wert === '' || wert === undefined ? null : wert))
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const DOKUMENT_ARTEN = [
'BESCHEID',
'ANTRAG',
'SCHREIBEN',
'RECHNUNG',
'KONTOAUSZUG',
'VERTRAG',
'BESCHLUSS',
'URKUNDE',
'GUTACHTEN',
'AUSWEIS',
'PROTOKOLL',
'QUITTUNG',
'MAHNUNG',
'VOLLMACHT',
'ATTEST',
'GRUNDBUCHAUSZUG',
'FOTO',
'SONSTIGES',
] as const
export const DOKUMENT_RICHTUNGEN = ['EINGANG', 'AUSGANG', 'INTERN'] as const
export const POSTEINGANG_STUFEN = ['NEU', 'GESICHTET', 'ZUGEORDNET', 'BEANTWORTET'] as const
export const POSTEINGANG_BESCHRIFTUNG: Record<(typeof POSTEINGANG_STUFEN)[number], string> = {
NEU: 'Neu',
GESICHTET: 'Gesichtet',
ZUGEORDNET: 'Zugeordnet',
BEANTWORTET: 'Beantwortet',
}
export const POSTEINGANG_ERKLAERUNG: Record<(typeof POSTEINGANG_STUFEN)[number], string> = {
NEU: 'Liegt im Posteingang und wurde noch nicht angesehen.',
GESICHTET: 'Angesehen, aber noch keinem Vorgang zugeordnet.',
ZUGEORDNET: 'Einem Vorgang, einer Frist oder einer Buchung zugeordnet.',
BEANTWORTET: 'Erledigt – beantwortet, bezahlt oder abgelegt.',
}
/** Alle Ziele, mit denen sich ein Dokument verknuepfen laesst. */
export const VERKNUEPFUNGSZIELE = [
'vorgang',
'frist',
'buchung',
'vermoegensposten',
'immobilie',
'vertrag',
'kontaktprotokoll',
'gesundheitsereignis',
'genehmigung',
'nachforderung',
'bescheid',
'kontakt',
] as const
export type Verknuepfungsziel = (typeof VERKNUEPFUNGSZIELE)[number]
export const ZIEL_BESCHRIFTUNG: Record<Verknuepfungsziel, string> = {
vorgang: 'Vorgang',
frist: 'Frist',
buchung: 'Buchung',
vermoegensposten: 'Vermögensposten',
immobilie: 'Immobilie',
vertrag: 'Vertrag',
kontaktprotokoll: 'Gesprächsnotiz',
gesundheitsereignis: 'Gesundheitsereignis',
genehmigung: 'Genehmigung',
nachforderung: 'Nachforderung',
bescheid: 'Bescheid',
kontakt: 'Kontakt',
}
/** Die Spalte in DokumentVerknuepfung, die zu einem Ziel gehoert. */
export const ZIEL_SPALTE: Record<Verknuepfungsziel, string> = {
vorgang: 'vorgangId',
frist: 'fristId',
buchung: 'buchungId',
vermoegensposten: 'vermoegenspostenId',
immobilie: 'immobilieId',
vertrag: 'vertragId',
kontaktprotokoll: 'kontaktprotokollId',
gesundheitsereignis: 'gesundheitsereignisId',
genehmigung: 'genehmigungId',
nachforderung: 'nachforderungId',
bescheid: 'bescheidId',
kontakt: 'kontaktId',
}
const kennung = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/, 'Diese Kennung sieht nicht wie eine gültige Kennung aus.')
// -----------------------------------------------------------------------------
// Metadaten
// -----------------------------------------------------------------------------
export const DokumentMetadatenSchema = z
.object({
titel: z
.string()
.trim()
.min(2, 'Bitte einen Titel angeben – daran findest du das Dokument später wieder.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
art: z.enum(DOKUMENT_ARTEN),
richtung: z.enum(DOKUMENT_RICHTUNGEN),
schreibenVom: optionalesDatum,
eingangAm: optionalesDatum,
versandAm: optionalesDatum,
absender: optionalerText(200),
empfaenger: optionalerText(200),
aktenzeichen: optionalerText(100),
/** Wo liegt das Original im Papierordner? Papier bleibt Papier. */
ablageortPapier: optionalerText(150),
wiedervorlageAm: optionalesDatum,
ablaufAm: optionalesDatum,
posteingangStatus: z.enum(POSTEINGANG_STUFEN).optional(),
notizen: optionalerText(4000),
/** Bestehende Tag-Kennungen. */
tagIds: z.array(kennung).max(20).optional(),
/** Neue Tags, mit Komma getrennt eingegeben. */
neueTags: optionalerText(200),
})
.refine(
(daten) =>
!daten.schreibenVom ||
!daten.eingangAm ||
daten.schreibenVom.getTime() <= daten.eingangAm.getTime(),
{
message: 'Das Schreiben kann nicht nach seinem Eingang datiert sein. Bitte prüfen.',
path: ['eingangAm'],
},
)
export type DokumentMetadaten = z.output<typeof DokumentMetadatenSchema>
// -----------------------------------------------------------------------------
// Upload
// -----------------------------------------------------------------------------
/**
* Beim Hochladen darf fast alles fehlen: Ziel ist, dass ein Foto in zwei
* Klicks in der Akte liegt. Die Metadaten kommen im zweiten Schritt aus den
* Erkennungsvorschlaegen.
*/
export const UploadSchema = z.object({
titel: optionalerText(200),
art: z.enum(DOKUMENT_ARTEN).optional(),
richtung: z.enum(DOKUMENT_RICHTUNGEN).optional(),
eingangAm: optionalesDatum,
absender: optionalerText(200),
aktenzeichen: optionalerText(100),
ablageortPapier: optionalerText(150),
/** Direkt beim Hochladen mit einem Vorgang verknuepfen. */
vorgangId: kennung.optional().nullable(),
})
export type Uploadeingabe = z.output<typeof UploadSchema>
// -----------------------------------------------------------------------------
// Verknuepfung
// -----------------------------------------------------------------------------
export const VerknuepfungSchema = z.object({
dokumentId: kennung,
ziel: z.enum(VERKNUEPFUNGSZIELE),
zielId: kennung,
bemerkung: optionalerText(200),
})
export const PosteingangSchema = z.object({
dokumentId: kennung,
status: z.enum(POSTEINGANG_STUFEN),
})
// -----------------------------------------------------------------------------
// Frist aus einem Erkennungsvorschlag
// -----------------------------------------------------------------------------
/**
* Aus einem Fristvorschlag wird nur dann eine Frist, wenn der Nutzer sie
* ausdruecklich uebernimmt. Das Schema prueft genau das, was er dabei
* bestaetigt hat - Datum und Titel sind Pflicht.
*/
export const FristUebernehmenSchema = z.object({
dokumentId: kennung,
titel: z
.string()
.trim()
.min(3, 'Bitte einen Titel angeben, an dem du die Frist wiedererkennst.')
.max(200),
datum: z
.string()
.trim()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Bitte ein Datum im Format TT.MM.JJJJ wählen.')
.transform((wert) => new Date(`${wert}T00:00:00.000Z`)),
typ: z.enum(['GESETZLICH', 'BEHOERDLICH', 'SELBST_GESETZT', 'WIEDERVORLAGE']).default('BEHOERDLICH'),
beschreibung: optionalerText(1000),
rechtsgrundlage: optionalerText(120),
/** Rechtsbehelfsfristen sind Ausschlussfristen - sie verfallen. */
ausschlussfrist: z.coerce.boolean().optional().default(false),
vorgangId: kennung.optional().nullable(),
})
// -----------------------------------------------------------------------------
// Filter der Liste
// -----------------------------------------------------------------------------
export const ListenfilterSchema = z.object({
suche: optionalerText(200),
art: z.enum(DOKUMENT_ARTEN).optional().nullable(),
richtung: z.enum(DOKUMENT_RICHTUNGEN).optional().nullable(),
posteingangStatus: z.enum(POSTEINGANG_STUFEN).optional().nullable(),
von: optionalesDatum,
bis: optionalesDatum,
tagId: kennung.optional().nullable(),
seite: z.coerce.number().int().min(1).max(1000).optional().default(1),
})
/**
* Liest die Filter aus den Suchparametern der Seite. Unbrauchbare Werte
* werden stillschweigend verworfen - eine kaputte URL soll die Liste nicht
* mit einer Fehlermeldung ersetzen.
*/
export function filterAusParametern(
parameter: Record<string, string | string[] | undefined>,
): z.output<typeof ListenfilterSchema> {
const einzeln = (name: string): string | undefined => {
const wert = parameter[name]
const roh = Array.isArray(wert) ? wert[0] : wert
return roh && roh.trim() !== '' ? roh : undefined
}
const gelesen = ListenfilterSchema.safeParse({
suche: einzeln('suche'),
art: einzeln('art'),
richtung: einzeln('richtung'),
posteingangStatus: einzeln('status'),
von: einzeln('von'),
bis: einzeln('bis'),
tagId: einzeln('tag'),
seite: einzeln('seite') ?? 1,
})
if (gelesen.success) return gelesen.data
return {
suche: einzeln('suche')?.slice(0, 200) ?? null,
art: null,
richtung: null,
posteingangStatus: null,
von: null,
bis: null,
tagId: null,
seite: 1,
}
}
/** Aus "Bezirk, Pflege , Wichtig" werden drei saubere Tag-Namen. */
export function tagsAufteilen(roh: string | null | undefined): string[] {
if (!roh) return []
return [
...new Set(
roh
.split(',')
.map((name) => name.trim().replace(/\s+/g, ' '))
.filter((name) => name.length >= 2 && name.length <= 40),
),
].slice(0, 20)
}
+5 -4
View File
@@ -101,7 +101,7 @@ const optionalesDatum = z
})
const pflichtDatum = z
.string()
.string({ required_error: 'Bitte ein Datum angeben.' })
.trim()
.transform((wert, ctx) => {
const datum = tagAusText(wert)
@@ -115,7 +115,7 @@ const pflichtDatum = z
/** Ein Formularfeld mit Geldbetrag wird hier zu Cent - und nie woanders. */
const betragFeld = (optionen: { nurPositiv?: boolean } = {}) =>
z
.string()
.string({ required_error: 'Bitte einen Betrag angeben.' })
.trim()
.transform((wert, ctx) => {
try {
@@ -142,8 +142,9 @@ const betragFeld = (optionen: { nurPositiv?: boolean } = {}) =>
/** Checkbox: vorhanden heisst an, fehlend heisst aus. */
const schalter = z
.union([z.literal('on'), z.literal('true'), z.literal('1'), z.literal(''), z.undefined()])
.transform((wert) => wert === 'on' || wert === 'true' || wert === '1')
.string()
.optional()
.transform((wert) => wert === 'on' || wert === 'true' || wert === '1' || wert === 'ja')
// -----------------------------------------------------------------------------
// Konto
+183
View File
@@ -0,0 +1,183 @@
import { z } from 'zod'
import type { Modul, Rolle } from '@prisma/client'
import { ALLE_MODULE, BESONDERS_SENSIBEL, darf, type Zugang } from '@/lib/auth/rollen'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Die eigentliche Zugriffsentscheidung faellt in src/lib/auth/rollen.ts.
* Hier steht, was beim AENDERN gilt: welche Eingaben zulaessig sind, was
* eine Freigabe an einen Mitleser bedeutet und wie sich eine Aenderung
* beschreiben laesst, damit sie im Audit-Log und in der Oberflaeche
* verstaendlich wird.
*
* Ein Satz steht ueber allem: Ein Mitleser schreibt nie. Nicht ueber die
* Oberflaeche, nicht ueber eine Server Action, nicht mit einer freigegebenen
* Modulliste. Das ist keine Frage der Konfiguration.
*/
export const ROLLEN = ['BETREUER', 'MITLESER', 'ADMIN'] as const
const rolleSchema = z.enum(ROLLEN, {
errorMap: () => ({ message: 'Bitte eine gültige Rolle wählen.' }),
})
const modulSchema = z.enum(ALLE_MODULE as [Modul, ...Modul[]], {
errorMap: () => ({ message: 'Unbekannter Bereich.' }),
})
export const kennung = z
.string()
.trim()
.regex(/^[A-Za-z0-9_-]{1,64}$/, 'Ungültige Kennung.')
export const benutzerAnlegenSchema = z.object({
name: z.string().trim().min(2, 'Bitte den vollständigen Namen angeben.').max(120),
email: z
.string()
.trim()
.toLowerCase()
.email('Das sieht nicht nach einer E-Mail-Adresse aus.')
.max(200),
rolle: rolleSchema,
passwort: z
.string()
.min(12, 'Mindestens 12 Zeichen. Ein Satz mit vier Wörtern ist leichter zu merken und sicherer.')
.max(200),
})
export const rolleAendernSchema = z.object({
userId: kennung,
rolle: rolleSchema,
})
export const benutzerAktivSchema = z.object({
userId: kennung,
aktiv: z.boolean(),
})
export const sitzungenBeendenSchema = z.object({ userId: kennung })
export const freigabenSchema = z.object({
userId: kennung,
betreuteId: kennung,
rolle: rolleSchema,
freigegebeneModule: z.array(modulSchema).default([]),
})
export const papierkorbSchema = z.object({
betreuteId: kennung,
entitaet: z.string().trim().min(2).max(60),
entitaetId: kennung,
})
export type BenutzerAnlegenEingabe = z.infer<typeof benutzerAnlegenSchema>
export type FreigabenEingabe = z.infer<typeof freigabenSchema>
export type PapierkorbEingabe = z.infer<typeof papierkorbSchema>
// -----------------------------------------------------------------------------
// Reine Regeln
// -----------------------------------------------------------------------------
export function istBesondersSensibel(modul: Modul): boolean {
return BESONDERS_SENSIBEL.includes(modul)
}
/**
* Eine Modulliste ergibt nur fuer Mitleser Sinn: Betreuer und Administrator
* sehen ohnehin alles. Wird jemand vom Mitleser zum Betreuer gemacht, wird
* die Liste geleert - sonst steht spaeter eine alte, irrefuehrende Auswahl
* in der Datenbank, die bei einer Rueckstufung ploetzlich wieder gilt.
*/
export function freigabenNormalisieren(rolle: Rolle, module: readonly Modul[]): Modul[] {
if (rolle !== 'MITLESER') return []
const gueltig = module.filter((modul) => ALLE_MODULE.includes(modul))
return ALLE_MODULE.filter((modul) => gueltig.includes(modul))
}
/** Was sieht dieser Zugang tatsaechlich? Grundlage fuer Navigation und Tests. */
export function sichtbareModule(zugang: Zugang | null): Modul[] {
return ALLE_MODULE.filter((modul) => darf(zugang, modul, 'lesen'))
}
/** Wo darf dieser Zugang schreiben? Fuer einen Mitleser: nirgends. */
export function beschreibbareModule(zugang: Zugang | null): Modul[] {
return ALLE_MODULE.filter((modul) => darf(zugang, modul, 'schreiben'))
}
export type Freigabeaenderung = {
hinzugefuegt: Modul[]
entfernt: Modul[]
/** Untermenge von hinzugefuegt - die Module, vor denen gewarnt wird. */
sensibelHinzugefuegt: Modul[]
rolleVorher: Rolle
rolleNachher: Rolle
rolleGeaendert: boolean
}
/**
* Vergleicht alte und neue Freigabe. Das Ergebnis geht in den Audit-Eintrag
* und in die Rueckmeldung an den Benutzer - "GESUNDHEIT freigegeben" ist
* eine Information, "Freigaben geaendert" ist keine.
*/
export function freigabeAenderung(
vorher: { rolle: Rolle; freigegebeneModule: readonly Modul[] },
nachher: { rolle: Rolle; freigegebeneModule: readonly Modul[] },
): Freigabeaenderung {
const alt = freigabenNormalisieren(vorher.rolle, vorher.freigegebeneModule)
const neu = freigabenNormalisieren(nachher.rolle, nachher.freigegebeneModule)
const hinzugefuegt = neu.filter((modul) => !alt.includes(modul))
const entfernt = alt.filter((modul) => !neu.includes(modul))
return {
hinzugefuegt,
entfernt,
sensibelHinzugefuegt: hinzugefuegt.filter(istBesondersSensibel),
rolleVorher: vorher.rolle,
rolleNachher: nachher.rolle,
rolleGeaendert: vorher.rolle !== nachher.rolle,
}
}
/**
* Darf dieser Benutzer die Verwaltung ueberhaupt bedienen? Nur der
* Administrator legt Benutzer an, aendert Rollen und beendet fremde
* Sitzungen.
*/
export function darfBenutzerVerwalten(rolle: Rolle): boolean {
return rolle === 'ADMIN'
}
/**
* Darf dieser Benutzer Freigaben fuer eine betreute Person vergeben?
* Der Betreuer dieser Person und der Administrator - ein Mitleser nie,
* sonst gaebe er sich selbst mehr Rechte.
*/
export function darfFreigabenVergeben(zugang: Zugang | null): boolean {
if (!zugang) return false
return zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN'
}
/**
* Der letzte Administrator darf sich weder herabstufen noch abschalten -
* sonst kommt niemand mehr an die Benutzerverwaltung heran.
*/
export function letzterAdminGeschuetzt(angaben: {
zielRolleVorher: Rolle
zielRolleNachher: Rolle
zielBleibtAktiv: boolean
anzahlAktiverAdmins: number
}): string | null {
const verliertAdmin =
angaben.zielRolleVorher === 'ADMIN' &&
(angaben.zielRolleNachher !== 'ADMIN' || !angaben.zielBleibtAktiv)
if (verliertAdmin && angaben.anzahlAktiverAdmins <= 1) {
return (
'Das ist der letzte aktive Administrator. Lege zuerst einen weiteren an – ' +
'sonst kommt niemand mehr an die Benutzerverwaltung.'
)
}
return null
}
+171
View File
@@ -0,0 +1,171 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import {
BELASTUNG_ARTEN,
BEWOHNT_STATUS,
GRUNDBUCH_ABTEILUNGEN,
WERTQUELLEN_IMMOBILIE,
} from '@/lib/immobilie/konstanten'
import { VERBLEIB_MOEGLICHKEITEN } from '@/lib/immobilie/inventar'
/**
* Eingabepruefung fuer das Immobilienmodul.
*
* Gepruefte Grundsaetze: Ein Wert ohne Quelle wird nicht angenommen, ein
* Status im Sozialhilfeverfahren ohne Begruendung ebenfalls nicht -
* beides wuerde spaeter im Gespraech mit dem Bezirk oder dem Gericht
* nicht standhalten.
*/
const IMMOBILIEN_STATUS = [
'UNGEKLAERT',
'SELBST_BEWOHNT_GESCHUETZT',
'HAERTEFALL_GELTEND_GEMACHT',
'DARLEHEN_91_SGB_XII',
'VERKAUF_LAEUFT',
'VERWERTET',
] as const
export const ImmobilieSchema = z
.object({
bezeichnung: pflichtText(150, 'Bitte eine Bezeichnung angeben, z. B. „Elternhaus Bayreuth“.'),
strasse: optionalerText(150),
plz: optionalerText(10),
ort: optionalerText(100),
grundbuchamt: optionalerText(150),
grundbuchBlatt: optionalerText(50),
gemarkung: optionalerText(100),
flurstueck: optionalerText(50),
eigentumsanteil: optionalerText(20),
wohnflaecheQm: optionaleZahl(1, 100_000, 'Bitte eine Wohnfläche in ganzen Quadratmetern.'),
grundstueckQm: optionaleZahl(1, 10_000_000, 'Bitte eine Fläche in ganzen Quadratmetern.'),
baujahr: optionaleZahl(1000, 2100, 'Bitte ein Baujahr zwischen 1000 und 2100.'),
bewohntStatus: z.enum(BEWOHNT_STATUS).nullable().catch(null),
leerstandSeit: optionalesDatum,
notizen: optionalerText(4000),
/** Beim Anlegen: Leerstands-Checkliste gleich mit übernehmen? */
checklisteUebernehmen: z.boolean().default(false),
})
.superRefine((daten, kontext) => {
if (daten.bewohntStatus === 'leerstehend' && !daten.leerstandSeit) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['leerstandSeit'],
message:
'Bitte angeben, seit wann das Objekt leersteht — davon hängen die ' +
'Fälligkeiten der Pflichten bei Leerstand ab.',
})
}
if (daten.leerstandSeit && daten.leerstandSeit.getTime() > Date.now() + 86_400_000) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['leerstandSeit'],
message: 'Der Leerstand kann nicht in der Zukunft beginnen.',
})
}
})
export type ImmobilieEingabe = z.infer<typeof ImmobilieSchema>
export const BelastungSchema = z
.object({
art: z.enum(BELASTUNG_ARTEN),
abteilung: z.enum(GRUNDBUCH_ABTEILUNGEN).nullable().catch(null),
laufendeNr: optionalerText(20),
glaeubiger: optionalerText(150),
betrag: optionalerBetrag,
bemerkung: optionalerText(1000),
geloeschtImGrundbuchAm: optionalesDatum,
})
.superRefine((daten, kontext) => {
if (
(daten.art === 'Grundschuld' || daten.art === 'Hypothek') &&
daten.betrag === null
) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['betrag'],
message:
'Bei einer Grundschuld oder Hypothek gehört der eingetragene Betrag dazu — ' +
'das ist der Nennbetrag aus Abteilung III, nicht die Restschuld.',
})
}
if (
(daten.art === 'Grundschuld' || daten.art === 'Hypothek') &&
!daten.glaeubiger
) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['glaeubiger'],
message: 'Bitte den eingetragenen Gläubiger angeben (meist die Bank).',
})
}
})
export type BelastungEingabe = z.infer<typeof BelastungSchema>
/**
* Wertstand: Stichtag, Betrag und Quelle. Die Quelle ist Pflicht - ein
* Wert ohne Herkunft ist gegenueber Gericht und Bezirk wertlos.
*/
export const WertstandSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_IMMOBILIE, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type WertstandEingabe = z.infer<typeof WertstandSchema>
export const StatusSozialhilfeSchema = z
.object({
statusSozialhilfe: z.enum(IMMOBILIEN_STATUS),
begruendung: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.statusSozialhilfe !== 'UNGEKLAERT' && !daten.begruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['begruendung'],
message:
'Bitte begründen, warum das Objekt so eingeordnet ist. Diese Begründung ist ' +
'deine Argumentation gegenüber dem Sozialhilfeträger — ohne sie steht im ' +
'Zweifel nur eine Behauptung in der Akte.',
})
}
})
export type StatusSozialhilfeEingabe = z.infer<typeof StatusSozialhilfeSchema>
export const CheckpunktSchema = z.object({
titel: pflichtText(200, 'Bitte einen Titel für den Punkt angeben.'),
beschreibung: optionalerText(2000),
faelligAm: optionalesDatum,
wiederholungRrule: optionalerText(200),
})
export type CheckpunktEingabe = z.infer<typeof CheckpunktSchema>
export const InventarSchema = z.object({
bezeichnung: pflichtText(150, 'Bitte den Gegenstand benennen.'),
raum: optionalerText(100),
geschaetzterWert: optionalerBetrag,
verbleib: z.enum(VERBLEIB_MOEGLICHKEITEN).nullable().catch(null),
bemerkung: optionalerText(1000),
})
export type InventarEingabe = z.infer<typeof InventarSchema>
+123
View File
@@ -0,0 +1,123 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
/**
* Eingabepruefung fuer Heim, Pflege und Gesundheitsverlauf.
*
* Freitexte zu Gesundheitsereignissen sind besondere Kategorien nach
* Art. 9 DSGVO. Sie werden hier nur geprueft; verschluesselt werden sie in
* der Server Action ueber sealen('Gesundheitsereignis', ...).
*/
export const GESUNDHEITS_ARTEN = [
'ARZTBESUCH',
'KRANKENHAUS',
'EXAZERBATION',
'STURZ',
'MEDIKATIONSAENDERUNG',
'SAUERSTOFFVERSORGUNG',
'INHALATION',
'HILFSMITTEL',
'IMPFUNG',
'REHA',
'PFLEGEBEGUTACHTUNG',
'HEILMITTEL',
'SONSTIGES',
] as const
export const HeimvertragSchema = z
.object({
einrichtung: pflichtText(150, 'Bitte den Namen der Einrichtung angeben.'),
traeger: optionalerText(150),
strasse: optionalerText(150),
plz: optionalerText(10),
ort: optionalerText(100),
vertragAm: optionalesDatum,
einzugAm: optionalesDatum,
auszugAm: optionalesDatum,
kuendigungsfristTage: optionaleZahl(
0,
365,
'Bitte die Kündigungsfrist in ganzen Tagen angeben.',
),
pflegeEigenanteil: optionalerBetrag,
unterkunftVerpflegung: optionalerBetrag,
investitionskosten: optionalerBetrag,
ausbildungsumlage: optionalerBetrag,
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.einzugAm && daten.auszugAm && daten.auszugAm.getTime() < daten.einzugAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['auszugAm'],
message: 'Der Auszug liegt vor dem Einzug.',
})
}
})
export type HeimvertragEingabe = z.infer<typeof HeimvertragSchema>
/**
* Gesundheitsereignis. Die Zusatzangaben der Schnellerfassung werden nicht
* hier geprueft, sondern in der Server Action zu einem Beschreibungstext
* zusammengesetzt (src/lib/pflege/ereignis-details.ts) - sonst braeuchte
* jedes Detailfeld ein eigenes Schema.
*/
export const GesundheitsereignisSchema = z
.object({
datum: pflichtDatum,
bisDatum: optionalesDatum,
art: z.enum(GESUNDHEITS_ARTEN),
titel: pflichtText(200, 'Bitte einen kurzen Titel angeben.'),
behandler: optionalerText(150),
einrichtung: optionalerText(150),
abwesenheitstage: optionaleZahl(0, 366, 'Bitte eine Zahl zwischen 0 und 366.'),
beschreibung: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.bisDatum && daten.bisDatum.getTime() < daten.datum.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bisDatum'],
message: 'Das Ende liegt vor dem Beginn.',
})
}
if (daten.art === 'KRANKENHAUS' && !daten.bisDatum && daten.abwesenheitstage === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bisDatum'],
message:
'Bitte das Entlassdatum eintragen — oder, solange der Aufenthalt noch läuft, ' +
'die bisherigen Abwesenheitstage. Ohne eine der beiden Angaben werden die ' +
'Tage nicht gezählt, und die Minderung des Eigenanteils fällt unter den Tisch.',
})
}
})
export type GesundheitsereignisEingabe = z.infer<typeof GesundheitsereignisSchema>
/** Der nach § 1821 BGB ermittelte Wille - verschlüsselt gespeichert. */
export const ErmittelterWilleSchema = z.object({
ermittelterWille: optionalerText(8000),
})
export type ErmittelterWilleEingabe = z.infer<typeof ErmittelterWilleSchema>
/** Termin für die Überprüfung des Pflegegrades. */
export const HoeherstufungSchema = z.object({
pruefungAm: pflichtDatum,
begruendung: optionalerText(2000),
})
export type HoeherstufungEingabe = z.infer<typeof HoeherstufungSchema>
+437
View File
@@ -0,0 +1,437 @@
import { z } from 'zod'
import { betragZuCent } from '@/lib/sozialhilfe/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragZuCent(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragZuCent(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
+167
View File
@@ -0,0 +1,167 @@
import { z } from 'zod'
/**
* Gemeinsame Bausteine der Eingabepruefung fuer Immobilie, Vertraege und
* Pflege (Etappe 6).
*
* Sie stehen hier und nicht dreimal nebeneinander, weil ein Cent-Parser,
* den es in drei Fassungen gibt, frueher oder spaeter in einer davon
* falsch rundet - und ein falsch gerundeter Cent laeuft bis in die
* Rechnungslegung nach § 1865 BGB durch.
*/
/** Leere Zeichenketten aus einem Formular werden null, nicht "". */
export const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
export const pflichtText = (max = 200, meldung = 'Bitte ausfüllen.') =>
z.string().trim().min(1, meldung).max(max, `Bitte höchstens ${max} Zeichen.`)
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
export const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Liest einen Geldbetrag aus einem Formularfeld als BigInt in Cent.
*
* Akzeptiert die deutsche Schreibweise ("1.234,56"), die englische
* ("1234.56") und blanke Zahlen. Gerechnet wird auf den Ziffern, nicht
* ueber Number - eine Multiplikation mit 100 auf einer Fliesskommazahl
* macht aus 8,15 Euro zuverlaessig 814 Cent.
*/
export function centAusText(text: string): bigint | null {
const sauber = text.trim().replace(/\s/g, '').replace(/€/g, '')
if (sauber === '') return null
const negativ = sauber.startsWith('-')
const ohneVorzeichen = negativ ? sauber.slice(1) : sauber
// Letztes Komma oder letzter Punkt trennt die Nachkommastellen, sofern
// danach ein oder zwei Ziffern stehen. "1.234" ist Tausender, "1.23" nicht.
const treffer = /^(\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,](\d{1,2}))?$/.exec(ohneVorzeichen)
if (!treffer) {
const einfach = /^(\d+)[.,](\d{1,2})$/.exec(ohneVorzeichen)
if (!einfach) return null
const euro = BigInt(einfach[1]!)
const cent = BigInt((einfach[2] ?? '0').padEnd(2, '0'))
const wert = euro * 100n + cent
return negativ ? -wert : wert
}
const euro = BigInt(treffer[1]!.replace(/[.\s]/g, ''))
const cent = BigInt((treffer[2] ?? '0').padEnd(2, '0'))
const wert = euro * 100n + cent
return negativ ? -wert : wert
}
/** Zod-Feld fuer einen optionalen Betrag. */
export const optionalerBetrag = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.superRefine((wert, kontext) => {
if (wert === null) return
if (centAusText(wert) === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
}
})
.transform((wert) => (wert === null ? null : centAusText(wert)))
export const pflichtBetrag = z
.string()
.trim()
.min(1, 'Bitte einen Betrag angeben.')
.superRefine((wert, kontext) => {
if (centAusText(wert) === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
}
})
.transform((wert) => centAusText(wert)!)
/** Optionale ganze Zahl aus einem Formularfeld. */
export const optionaleZahl = (min: number, max: number, meldung?: string) =>
z
.union([z.string(), z.number(), z.null(), z.undefined()])
.transform((wert) => {
if (wert === null || wert === undefined) return null
const text = String(wert).trim()
return text === '' ? null : text
})
.superRefine((wert, kontext) => {
if (wert === null) return
const zahl = Number(wert)
if (!Number.isInteger(zahl) || zahl < min || zahl > max) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: meldung ?? `Bitte eine ganze Zahl zwischen ${min} und ${max}.`,
})
}
})
.transform((wert) => (wert === null ? null : Number(wert)))
/** Kontrollkaestchen liefern "on" oder gar nichts. */
export function haken(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** FormData in ein flaches Objekt - Dateien werden ignoriert. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Der Rueckgabetyp aller Formular-Aktionen dieser Etappe. Fehler werden
* zurueckgegeben, nie geworfen - ein geworfener Fehler zeigt dem Nutzer
* eine Fehlerseite und verliert seine Eingaben.
*/
export type Formularzustand = {
fehler?: string
erfolg?: string
felder?: Record<string, string>
}
/** Uebersetzt einen Zod-Fehler in den Formularzustand. */
export function zodFehler(
fehler: z.ZodError,
meldung = 'Bitte prüfe deine Eingaben.',
): Formularzustand {
const felder: Record<string, string> = {}
for (const [name, meldungen] of Object.entries(fehler.flatten().fieldErrors)) {
const erste = meldungen?.[0]
if (erste) felder[name] = erste
}
return { fehler: meldung, felder }
}
+172
View File
@@ -0,0 +1,172 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
laufzeitJahre: optionaleZahl(1, 99, 'Bitte die Laufzeit in ganzen Jahren angeben.'),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>