Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit "git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich kein Zeichen Inhalt geaendert. Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit echten Aenderungen zu verstecken, macht die Historie an dieser Stelle unlesbar. So laesst er sich beim Durchsehen ueberspringen. Refs #26
This commit is contained in:
+213
-213
@@ -1,213 +1,213 @@
|
||||
import 'server-only'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
Footer,
|
||||
Header,
|
||||
HeadingLevel,
|
||||
PageNumber,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
convertMillimetersToTwip,
|
||||
} from 'docx'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
|
||||
|
||||
/**
|
||||
* DOCX-Ausgabe.
|
||||
*
|
||||
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
|
||||
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
|
||||
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
|
||||
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
|
||||
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
|
||||
*
|
||||
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
|
||||
* Arbeitsfassung.
|
||||
*
|
||||
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
|
||||
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
|
||||
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
|
||||
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
|
||||
*/
|
||||
|
||||
export type DocxKopf = {
|
||||
titel: string
|
||||
betreute: { name: string; geburtsdatum: Date | null }
|
||||
behoerde?: string
|
||||
aktenzeichen?: string
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
stichtag?: Date | null
|
||||
rechtsgrundlage?: string
|
||||
erstelltAm: Date
|
||||
erstelltVon: string
|
||||
}
|
||||
|
||||
const SCHRIFT = 'Arial'
|
||||
|
||||
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
|
||||
return new TextRun({
|
||||
text,
|
||||
bold: optionen.fett ?? false,
|
||||
color: optionen.grau ? '555555' : '000000',
|
||||
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
|
||||
font: SCHRIFT,
|
||||
})
|
||||
}
|
||||
|
||||
function kopfzeilen(kopf: DocxKopf): string[] {
|
||||
const zeilen: string[] = [
|
||||
`Betreute Person: ${kopf.betreute.name}` +
|
||||
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
|
||||
]
|
||||
if (kopf.behoerde) zeilen.push(kopf.behoerde)
|
||||
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
|
||||
if (kopf.zeitraumVon || kopf.zeitraumBis) {
|
||||
zeilen.push(
|
||||
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
|
||||
)
|
||||
}
|
||||
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
|
||||
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
|
||||
return zeilen
|
||||
}
|
||||
|
||||
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
|
||||
return [
|
||||
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
|
||||
new Paragraph({
|
||||
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
|
||||
spacing: { after: 500 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
|
||||
spacing: { after: 60 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
|
||||
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
|
||||
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
|
||||
*/
|
||||
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
|
||||
const abschnitte = textZuAbschnitten(text)
|
||||
|
||||
const inhalt: Paragraph[] = [
|
||||
new Paragraph({
|
||||
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
|
||||
spacing: { after: 160 },
|
||||
}),
|
||||
...kopfzeilen(kopf).map(
|
||||
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
|
||||
),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (abschnitt.titel) {
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 260, after: 100 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const absatz of abschnitt.absaetze) {
|
||||
const istAufzaehlung = absatz.startsWith('•')
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
|
||||
bullet: istAufzaehlung ? { level: 0 } : undefined,
|
||||
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
|
||||
spacing: { after: 120, line: 300 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inhalt.push(...unterschriftenblock(kopf))
|
||||
|
||||
return new Document({
|
||||
creator: kopf.erstelltVon,
|
||||
title: kopf.titel,
|
||||
description: `${kopf.titel} für ${kopf.betreute.name}`,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: convertMillimetersToTwip(25),
|
||||
bottom: convertMillimetersToTwip(20),
|
||||
left: convertMillimetersToTwip(25),
|
||||
right: convertMillimetersToTwip(20),
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.RIGHT,
|
||||
children: [
|
||||
zeile(
|
||||
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
|
||||
{ groesse: 8, grau: true },
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
zeile('Seite ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.CURRENT],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(' von ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.TOTAL_PAGES],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
|
||||
groesse: 8,
|
||||
grau: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children: inhalt,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** Fertige .docx-Datei als Buffer. */
|
||||
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
|
||||
return Packer.toBuffer(docxAusEntwurf(kopf, text))
|
||||
}
|
||||
import 'server-only'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
Footer,
|
||||
Header,
|
||||
HeadingLevel,
|
||||
PageNumber,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
convertMillimetersToTwip,
|
||||
} from 'docx'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
|
||||
|
||||
/**
|
||||
* DOCX-Ausgabe.
|
||||
*
|
||||
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
|
||||
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
|
||||
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
|
||||
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
|
||||
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
|
||||
*
|
||||
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
|
||||
* Arbeitsfassung.
|
||||
*
|
||||
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
|
||||
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
|
||||
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
|
||||
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
|
||||
*/
|
||||
|
||||
export type DocxKopf = {
|
||||
titel: string
|
||||
betreute: { name: string; geburtsdatum: Date | null }
|
||||
behoerde?: string
|
||||
aktenzeichen?: string
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
stichtag?: Date | null
|
||||
rechtsgrundlage?: string
|
||||
erstelltAm: Date
|
||||
erstelltVon: string
|
||||
}
|
||||
|
||||
const SCHRIFT = 'Arial'
|
||||
|
||||
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
|
||||
return new TextRun({
|
||||
text,
|
||||
bold: optionen.fett ?? false,
|
||||
color: optionen.grau ? '555555' : '000000',
|
||||
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
|
||||
font: SCHRIFT,
|
||||
})
|
||||
}
|
||||
|
||||
function kopfzeilen(kopf: DocxKopf): string[] {
|
||||
const zeilen: string[] = [
|
||||
`Betreute Person: ${kopf.betreute.name}` +
|
||||
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
|
||||
]
|
||||
if (kopf.behoerde) zeilen.push(kopf.behoerde)
|
||||
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
|
||||
if (kopf.zeitraumVon || kopf.zeitraumBis) {
|
||||
zeilen.push(
|
||||
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
|
||||
)
|
||||
}
|
||||
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
|
||||
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
|
||||
return zeilen
|
||||
}
|
||||
|
||||
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
|
||||
return [
|
||||
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
|
||||
new Paragraph({
|
||||
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
|
||||
spacing: { after: 500 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
|
||||
spacing: { after: 60 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
|
||||
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
|
||||
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
|
||||
*/
|
||||
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
|
||||
const abschnitte = textZuAbschnitten(text)
|
||||
|
||||
const inhalt: Paragraph[] = [
|
||||
new Paragraph({
|
||||
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
|
||||
spacing: { after: 160 },
|
||||
}),
|
||||
...kopfzeilen(kopf).map(
|
||||
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
|
||||
),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (abschnitt.titel) {
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 260, after: 100 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const absatz of abschnitt.absaetze) {
|
||||
const istAufzaehlung = absatz.startsWith('•')
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
|
||||
bullet: istAufzaehlung ? { level: 0 } : undefined,
|
||||
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
|
||||
spacing: { after: 120, line: 300 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inhalt.push(...unterschriftenblock(kopf))
|
||||
|
||||
return new Document({
|
||||
creator: kopf.erstelltVon,
|
||||
title: kopf.titel,
|
||||
description: `${kopf.titel} für ${kopf.betreute.name}`,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: convertMillimetersToTwip(25),
|
||||
bottom: convertMillimetersToTwip(20),
|
||||
left: convertMillimetersToTwip(25),
|
||||
right: convertMillimetersToTwip(20),
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.RIGHT,
|
||||
children: [
|
||||
zeile(
|
||||
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
|
||||
{ groesse: 8, grau: true },
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
zeile('Seite ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.CURRENT],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(' von ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.TOTAL_PAGES],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
|
||||
groesse: 8,
|
||||
grau: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children: inhalt,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** Fertige .docx-Datei als Buffer. */
|
||||
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
|
||||
return Packer.toBuffer(docxAusEntwurf(kopf, text))
|
||||
}
|
||||
|
||||
+214
-214
@@ -1,214 +1,214 @@
|
||||
import 'server-only'
|
||||
import type * as React from 'react'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
|
||||
import { typErkennen } from '@/lib/speicher/dateityp'
|
||||
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||
|
||||
/**
|
||||
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
|
||||
*
|
||||
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
|
||||
*
|
||||
* Zwei Wege fuehren hier zusammen:
|
||||
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
|
||||
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
|
||||
* (Belegmappe, Unterlagenpaket fuer das Gericht)
|
||||
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
|
||||
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
|
||||
*/
|
||||
|
||||
/** A4 in PDF-Punkten (72 dpi). */
|
||||
export const A4_BREITE = 595.28
|
||||
export const A4_HOEHE = 841.89
|
||||
const RAND = 36
|
||||
|
||||
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
|
||||
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
|
||||
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
|
||||
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
|
||||
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
|
||||
standardschriftenSicherstellen()
|
||||
|
||||
// renderToBuffer erwartet ein <Document>-Element; die Typen von
|
||||
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
||||
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
|
||||
return Buffer.from(roh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt ein Bild in ein einseitiges PDF.
|
||||
*
|
||||
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
|
||||
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
|
||||
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
|
||||
*/
|
||||
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
|
||||
const typ = typErkennen(inhalt)
|
||||
if (!typ || !typ.istBild) {
|
||||
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
|
||||
}
|
||||
|
||||
let daten = inhalt
|
||||
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
|
||||
|
||||
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
|
||||
const sharp = (await import('sharp')).default
|
||||
daten = await sharp(inhalt).rotate().png().toBuffer()
|
||||
format = 'png'
|
||||
}
|
||||
|
||||
const pdf = await PDFDocument.create()
|
||||
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
|
||||
|
||||
const beschriftungshoehe = beschriftung ? 16 : 0
|
||||
const nutzbareBreite = A4_BREITE - 2 * RAND
|
||||
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
|
||||
|
||||
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
|
||||
const querformat = bild.width > bild.height
|
||||
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
|
||||
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
|
||||
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
|
||||
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
|
||||
|
||||
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
|
||||
const breite = bild.width * massstab
|
||||
const hoehe = bild.height * massstab
|
||||
|
||||
const seite = pdf.addPage([seitenBreite, seitenHoehe])
|
||||
seite.drawImage(bild, {
|
||||
x: (seitenBreite - breite) / 2,
|
||||
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
|
||||
width: breite,
|
||||
height: hoehe,
|
||||
})
|
||||
|
||||
if (beschriftung) {
|
||||
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
|
||||
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
|
||||
x: RAND,
|
||||
y: RAND / 2,
|
||||
size: 8,
|
||||
font: schrift,
|
||||
color: rgb(0.3, 0.3, 0.3),
|
||||
})
|
||||
}
|
||||
|
||||
return Buffer.from(await pdf.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
|
||||
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
|
||||
*/
|
||||
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
|
||||
const ziel = await PDFDocument.create()
|
||||
ziel.setProducer('Betreuungs-Cockpit')
|
||||
ziel.setCreator('Betreuungs-Cockpit')
|
||||
|
||||
for (const teil of teile) {
|
||||
const typ = typErkennen(teil)
|
||||
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
|
||||
|
||||
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
|
||||
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
|
||||
for (const seite of seiten) ziel.addPage(seite)
|
||||
}
|
||||
|
||||
if (ziel.getPageCount() === 0) {
|
||||
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
|
||||
ziel.addPage([A4_BREITE, A4_HOEHE])
|
||||
}
|
||||
|
||||
return Buffer.from(await ziel.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
|
||||
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
|
||||
*/
|
||||
export async function seitenzahl(pdf: Buffer): Promise<number> {
|
||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||
return dokument.getPageCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
|
||||
*
|
||||
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
|
||||
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
|
||||
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
|
||||
*/
|
||||
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
|
||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
|
||||
const seiten = dokument.getPages()
|
||||
const gesamt = seiten.length
|
||||
|
||||
seiten.forEach((seite, i) => {
|
||||
const { width, height } = seite.getSize()
|
||||
const drehung = seite.getRotation().angle % 360
|
||||
const zaehlung = `Seite ${i + 1} von ${gesamt}`
|
||||
const text = kuerzenFuerSchrift(fusszeile, 120)
|
||||
const groesse = 7.5
|
||||
|
||||
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
|
||||
// gedreht, sonst steht sie quer im Blatt.
|
||||
if (drehung === 90 || drehung === 270) {
|
||||
seite.drawText(`${text} · ${zaehlung}`, {
|
||||
x: drehung === 90 ? 18 : width - 18,
|
||||
y: drehung === 90 ? 20 : height - 20,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
rotate: degrees(drehung),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
seite.drawText(text, {
|
||||
x: 36,
|
||||
y: 18,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
})
|
||||
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
|
||||
seite.drawText(zaehlung, {
|
||||
x: width - 36 - breite,
|
||||
y: 18,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
})
|
||||
})
|
||||
|
||||
return Buffer.from(await dokument.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
|
||||
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
|
||||
* deshalb hier ersetzen statt riskieren.
|
||||
*/
|
||||
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||
|
||||
export function fuerStandardschrift(text: string): string {
|
||||
return text
|
||||
.replace(/[\u2013\u2014]/g, '-')
|
||||
.replace(/[\u2018\u2019\u201a]/g, "'")
|
||||
.replace(/[\u201c\u201d\u201e]/g, '"')
|
||||
.replace(/\u2026/g, '...')
|
||||
.replace(/\u20ac/g, 'EUR')
|
||||
.replace(/[\u00a0\u2007\u202f]/g, ' ')
|
||||
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
|
||||
// Speichern des PDFs scheitern zu lassen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
|
||||
}
|
||||
|
||||
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
|
||||
const sauber = fuerStandardschrift(text)
|
||||
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
|
||||
}
|
||||
import 'server-only'
|
||||
import type * as React from 'react'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
|
||||
import { typErkennen } from '@/lib/speicher/dateityp'
|
||||
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||
|
||||
/**
|
||||
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
|
||||
*
|
||||
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
|
||||
*
|
||||
* Zwei Wege fuehren hier zusammen:
|
||||
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
|
||||
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
|
||||
* (Belegmappe, Unterlagenpaket fuer das Gericht)
|
||||
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
|
||||
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
|
||||
*/
|
||||
|
||||
/** A4 in PDF-Punkten (72 dpi). */
|
||||
export const A4_BREITE = 595.28
|
||||
export const A4_HOEHE = 841.89
|
||||
const RAND = 36
|
||||
|
||||
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
|
||||
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
|
||||
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
|
||||
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
|
||||
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
|
||||
standardschriftenSicherstellen()
|
||||
|
||||
// renderToBuffer erwartet ein <Document>-Element; die Typen von
|
||||
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
||||
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
|
||||
return Buffer.from(roh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt ein Bild in ein einseitiges PDF.
|
||||
*
|
||||
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
|
||||
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
|
||||
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
|
||||
*/
|
||||
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
|
||||
const typ = typErkennen(inhalt)
|
||||
if (!typ || !typ.istBild) {
|
||||
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
|
||||
}
|
||||
|
||||
let daten = inhalt
|
||||
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
|
||||
|
||||
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
|
||||
const sharp = (await import('sharp')).default
|
||||
daten = await sharp(inhalt).rotate().png().toBuffer()
|
||||
format = 'png'
|
||||
}
|
||||
|
||||
const pdf = await PDFDocument.create()
|
||||
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
|
||||
|
||||
const beschriftungshoehe = beschriftung ? 16 : 0
|
||||
const nutzbareBreite = A4_BREITE - 2 * RAND
|
||||
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
|
||||
|
||||
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
|
||||
const querformat = bild.width > bild.height
|
||||
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
|
||||
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
|
||||
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
|
||||
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
|
||||
|
||||
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
|
||||
const breite = bild.width * massstab
|
||||
const hoehe = bild.height * massstab
|
||||
|
||||
const seite = pdf.addPage([seitenBreite, seitenHoehe])
|
||||
seite.drawImage(bild, {
|
||||
x: (seitenBreite - breite) / 2,
|
||||
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
|
||||
width: breite,
|
||||
height: hoehe,
|
||||
})
|
||||
|
||||
if (beschriftung) {
|
||||
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
|
||||
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
|
||||
x: RAND,
|
||||
y: RAND / 2,
|
||||
size: 8,
|
||||
font: schrift,
|
||||
color: rgb(0.3, 0.3, 0.3),
|
||||
})
|
||||
}
|
||||
|
||||
return Buffer.from(await pdf.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
|
||||
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
|
||||
*/
|
||||
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
|
||||
const ziel = await PDFDocument.create()
|
||||
ziel.setProducer('Betreuungs-Cockpit')
|
||||
ziel.setCreator('Betreuungs-Cockpit')
|
||||
|
||||
for (const teil of teile) {
|
||||
const typ = typErkennen(teil)
|
||||
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
|
||||
|
||||
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
|
||||
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
|
||||
for (const seite of seiten) ziel.addPage(seite)
|
||||
}
|
||||
|
||||
if (ziel.getPageCount() === 0) {
|
||||
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
|
||||
ziel.addPage([A4_BREITE, A4_HOEHE])
|
||||
}
|
||||
|
||||
return Buffer.from(await ziel.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
|
||||
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
|
||||
*/
|
||||
export async function seitenzahl(pdf: Buffer): Promise<number> {
|
||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||
return dokument.getPageCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
|
||||
*
|
||||
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
|
||||
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
|
||||
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
|
||||
*/
|
||||
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
|
||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
|
||||
const seiten = dokument.getPages()
|
||||
const gesamt = seiten.length
|
||||
|
||||
seiten.forEach((seite, i) => {
|
||||
const { width, height } = seite.getSize()
|
||||
const drehung = seite.getRotation().angle % 360
|
||||
const zaehlung = `Seite ${i + 1} von ${gesamt}`
|
||||
const text = kuerzenFuerSchrift(fusszeile, 120)
|
||||
const groesse = 7.5
|
||||
|
||||
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
|
||||
// gedreht, sonst steht sie quer im Blatt.
|
||||
if (drehung === 90 || drehung === 270) {
|
||||
seite.drawText(`${text} · ${zaehlung}`, {
|
||||
x: drehung === 90 ? 18 : width - 18,
|
||||
y: drehung === 90 ? 20 : height - 20,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
rotate: degrees(drehung),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
seite.drawText(text, {
|
||||
x: 36,
|
||||
y: 18,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
})
|
||||
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
|
||||
seite.drawText(zaehlung, {
|
||||
x: width - 36 - breite,
|
||||
y: 18,
|
||||
size: groesse,
|
||||
font: schrift,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
})
|
||||
})
|
||||
|
||||
return Buffer.from(await dokument.save())
|
||||
}
|
||||
|
||||
/**
|
||||
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
|
||||
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
|
||||
* deshalb hier ersetzen statt riskieren.
|
||||
*/
|
||||
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||
|
||||
export function fuerStandardschrift(text: string): string {
|
||||
return text
|
||||
.replace(/[\u2013\u2014]/g, '-')
|
||||
.replace(/[\u2018\u2019\u201a]/g, "'")
|
||||
.replace(/[\u201c\u201d\u201e]/g, '"')
|
||||
.replace(/\u2026/g, '...')
|
||||
.replace(/\u20ac/g, 'EUR')
|
||||
.replace(/[\u00a0\u2007\u202f]/g, ' ')
|
||||
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
|
||||
// Speichern des PDFs scheitern zu lassen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
|
||||
}
|
||||
|
||||
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
|
||||
const sauber = fuerStandardschrift(text)
|
||||
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
|
||||
}
|
||||
|
||||
+237
-237
@@ -1,237 +1,237 @@
|
||||
/**
|
||||
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
|
||||
*
|
||||
* ACHTUNG - EINZIGE STELLE.
|
||||
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
|
||||
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
|
||||
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
|
||||
* gefunden.
|
||||
*
|
||||
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
|
||||
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
|
||||
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
|
||||
* gehören die Werte hier nachgeprüft.
|
||||
*/
|
||||
|
||||
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
|
||||
export const KONSTANTEN_STAND = '2025-01-01'
|
||||
|
||||
/**
|
||||
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
|
||||
* blendet ab diesem Datum einen Prüfhinweis ein.
|
||||
*/
|
||||
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
|
||||
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
|
||||
* Fortschreibung ergab rechnerisch keine Erhöhung).
|
||||
*
|
||||
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
|
||||
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
|
||||
* doppelt gepflegt.
|
||||
*/
|
||||
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
|
||||
|
||||
/**
|
||||
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
|
||||
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
|
||||
* Regelbedarfsstufe 1.
|
||||
*
|
||||
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
|
||||
*
|
||||
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
|
||||
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
|
||||
*/
|
||||
export const BARBETRAG_ANTEIL_PROZENT = 27
|
||||
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
|
||||
|
||||
/**
|
||||
* Bekleidungspauschale.
|
||||
*
|
||||
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
|
||||
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
|
||||
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
|
||||
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
|
||||
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
|
||||
* schädlicher als gar keine.
|
||||
*/
|
||||
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
|
||||
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
|
||||
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
|
||||
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Leistungen der Pflegekasse, § 43 SGB XI
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
/**
|
||||
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
|
||||
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
|
||||
* Pflegeunterstützungs- und -entlastungsgesetz).
|
||||
*
|
||||
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
|
||||
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
|
||||
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
|
||||
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
|
||||
*
|
||||
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
|
||||
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
|
||||
*/
|
||||
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
|
||||
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
|
||||
2: 80_500n,
|
||||
3: 131_900n,
|
||||
4: 185_500n,
|
||||
5: 209_600n,
|
||||
}
|
||||
|
||||
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
|
||||
1: '§ 43 Abs. 3 SGB XI',
|
||||
2: '§ 43 Abs. 2 SGB XI',
|
||||
3: '§ 43 Abs. 2 SGB XI',
|
||||
4: '§ 43 Abs. 2 SGB XI',
|
||||
5: '§ 43 Abs. 2 SGB XI',
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Leistungszuschlag, § 43c SGB XI
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
|
||||
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
|
||||
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
|
||||
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
|
||||
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
|
||||
// zuverlaessig nur an einer Stelle nachgezogen.
|
||||
//
|
||||
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
|
||||
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
|
||||
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
|
||||
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Abwesenheit (Krankenhaus, Reha, Urlaub)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
|
||||
*
|
||||
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
|
||||
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
|
||||
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
|
||||
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
|
||||
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
|
||||
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
|
||||
*
|
||||
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
|
||||
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
|
||||
* gilt, steht in der Heimrechnung.
|
||||
*/
|
||||
export const ABWESENHEIT_KARENZTAGE = 3
|
||||
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
|
||||
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vermögen, § 90 SGB XII
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
|
||||
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
|
||||
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
|
||||
*
|
||||
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
|
||||
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
|
||||
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
|
||||
*/
|
||||
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
|
||||
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
|
||||
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
|
||||
|
||||
/**
|
||||
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
|
||||
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
|
||||
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
|
||||
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
|
||||
* notariellen Übertragungen der letzten zehn Jahre.
|
||||
*/
|
||||
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rechtsbehelf
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
|
||||
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
|
||||
|
||||
/**
|
||||
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
|
||||
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
|
||||
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
|
||||
*/
|
||||
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
|
||||
|
||||
/**
|
||||
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
|
||||
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
|
||||
* (§ 37 Abs. 2 Satz 1 SGB X).
|
||||
*/
|
||||
export const ZUGANGSVERMUTUNG_TAGE = 3
|
||||
|
||||
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
|
||||
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
|
||||
|
||||
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
|
||||
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Weitere Leistungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
|
||||
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
|
||||
* Landespflegegeldgesetz - BayLPflGG).
|
||||
*
|
||||
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
|
||||
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
|
||||
* beim Landesamt für Pflege prüfen.
|
||||
*/
|
||||
export const LANDESPFLEGEGELD_CENT = 100_000n
|
||||
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
|
||||
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
|
||||
|
||||
/**
|
||||
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
|
||||
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
|
||||
* chronisch Kranken bei 1 %.
|
||||
*
|
||||
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
|
||||
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
|
||||
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
|
||||
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
|
||||
*
|
||||
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
|
||||
*/
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
|
||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
|
||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
|
||||
|
||||
/**
|
||||
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
|
||||
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
|
||||
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
|
||||
*/
|
||||
export const RUNDFUNKBEITRAG_CENT = 1_836n
|
||||
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
|
||||
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
|
||||
/**
|
||||
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
|
||||
*
|
||||
* ACHTUNG - EINZIGE STELLE.
|
||||
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
|
||||
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
|
||||
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
|
||||
* gefunden.
|
||||
*
|
||||
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
|
||||
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
|
||||
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
|
||||
* gehören die Werte hier nachgeprüft.
|
||||
*/
|
||||
|
||||
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
|
||||
export const KONSTANTEN_STAND = '2025-01-01'
|
||||
|
||||
/**
|
||||
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
|
||||
* blendet ab diesem Datum einen Prüfhinweis ein.
|
||||
*/
|
||||
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
|
||||
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
|
||||
* Fortschreibung ergab rechnerisch keine Erhöhung).
|
||||
*
|
||||
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
|
||||
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
|
||||
* doppelt gepflegt.
|
||||
*/
|
||||
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
|
||||
|
||||
/**
|
||||
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
|
||||
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
|
||||
* Regelbedarfsstufe 1.
|
||||
*
|
||||
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
|
||||
*
|
||||
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
|
||||
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
|
||||
*/
|
||||
export const BARBETRAG_ANTEIL_PROZENT = 27
|
||||
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
|
||||
|
||||
/**
|
||||
* Bekleidungspauschale.
|
||||
*
|
||||
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
|
||||
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
|
||||
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
|
||||
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
|
||||
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
|
||||
* schädlicher als gar keine.
|
||||
*/
|
||||
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
|
||||
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
|
||||
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
|
||||
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Leistungen der Pflegekasse, § 43 SGB XI
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
/**
|
||||
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
|
||||
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
|
||||
* Pflegeunterstützungs- und -entlastungsgesetz).
|
||||
*
|
||||
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
|
||||
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
|
||||
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
|
||||
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
|
||||
*
|
||||
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
|
||||
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
|
||||
*/
|
||||
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
|
||||
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
|
||||
2: 80_500n,
|
||||
3: 131_900n,
|
||||
4: 185_500n,
|
||||
5: 209_600n,
|
||||
}
|
||||
|
||||
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
|
||||
1: '§ 43 Abs. 3 SGB XI',
|
||||
2: '§ 43 Abs. 2 SGB XI',
|
||||
3: '§ 43 Abs. 2 SGB XI',
|
||||
4: '§ 43 Abs. 2 SGB XI',
|
||||
5: '§ 43 Abs. 2 SGB XI',
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Leistungszuschlag, § 43c SGB XI
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
|
||||
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
|
||||
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
|
||||
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
|
||||
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
|
||||
// zuverlaessig nur an einer Stelle nachgezogen.
|
||||
//
|
||||
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
|
||||
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
|
||||
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
|
||||
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Abwesenheit (Krankenhaus, Reha, Urlaub)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
|
||||
*
|
||||
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
|
||||
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
|
||||
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
|
||||
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
|
||||
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
|
||||
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
|
||||
*
|
||||
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
|
||||
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
|
||||
* gilt, steht in der Heimrechnung.
|
||||
*/
|
||||
export const ABWESENHEIT_KARENZTAGE = 3
|
||||
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
|
||||
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vermögen, § 90 SGB XII
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
|
||||
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
|
||||
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
|
||||
*
|
||||
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
|
||||
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
|
||||
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
|
||||
*/
|
||||
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
|
||||
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
|
||||
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
|
||||
|
||||
/**
|
||||
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
|
||||
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
|
||||
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
|
||||
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
|
||||
* notariellen Übertragungen der letzten zehn Jahre.
|
||||
*/
|
||||
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rechtsbehelf
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
|
||||
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
|
||||
|
||||
/**
|
||||
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
|
||||
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
|
||||
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
|
||||
*/
|
||||
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
|
||||
|
||||
/**
|
||||
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
|
||||
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
|
||||
* (§ 37 Abs. 2 Satz 1 SGB X).
|
||||
*/
|
||||
export const ZUGANGSVERMUTUNG_TAGE = 3
|
||||
|
||||
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
|
||||
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
|
||||
|
||||
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
|
||||
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Weitere Leistungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
|
||||
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
|
||||
* Landespflegegeldgesetz - BayLPflGG).
|
||||
*
|
||||
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
|
||||
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
|
||||
* beim Landesamt für Pflege prüfen.
|
||||
*/
|
||||
export const LANDESPFLEGEGELD_CENT = 100_000n
|
||||
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
|
||||
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
|
||||
|
||||
/**
|
||||
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
|
||||
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
|
||||
* chronisch Kranken bei 1 %.
|
||||
*
|
||||
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
|
||||
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
|
||||
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
|
||||
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
|
||||
*
|
||||
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
|
||||
*/
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
|
||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
|
||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
|
||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
|
||||
|
||||
/**
|
||||
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
|
||||
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
|
||||
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
|
||||
*/
|
||||
export const RUNDFUNKBEITRAG_CENT = 1_836n
|
||||
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
|
||||
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
|
||||
|
||||
+437
-437
@@ -1,437 +1,437 @@
|
||||
import { z } from 'zod'
|
||||
import { betragParsenWennMoeglich } from '@/lib/finanzen/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 = betragParsenWennMoeglich(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 = betragParsenWennMoeglich(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
|
||||
}
|
||||
import { z } from 'zod'
|
||||
import { betragParsenWennMoeglich } from '@/lib/finanzen/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 = betragParsenWennMoeglich(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 = betragParsenWennMoeglich(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
|
||||
}
|
||||
|
||||
+171
-171
@@ -1,171 +1,171 @@
|
||||
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.',
|
||||
),
|
||||
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>
|
||||
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.',
|
||||
),
|
||||
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>
|
||||
|
||||
+227
-227
@@ -1,227 +1,227 @@
|
||||
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
|
||||
|
||||
/**
|
||||
* Kuendigungsfrist-Rechner.
|
||||
*
|
||||
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
|
||||
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
|
||||
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
|
||||
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
|
||||
* verpasst und zahlt ein weiteres Jahr.
|
||||
*
|
||||
* Deshalb rechnet diese Datei drei Daten aus:
|
||||
*
|
||||
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
|
||||
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
|
||||
* 3. den spaetesten Absendetag mit Postlaufpuffer.
|
||||
*
|
||||
* Reine Logik, keine Datenbank.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
|
||||
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
|
||||
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
|
||||
* Zugang und ist am selben Tag da.
|
||||
*/
|
||||
export const POSTLAUF_PUFFER_TAGE = 3
|
||||
|
||||
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
|
||||
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
|
||||
|
||||
/**
|
||||
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
|
||||
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
|
||||
*/
|
||||
export const STANDARD_LAUFZEIT_JAHRE = 1
|
||||
|
||||
export type KuendigungEingabe = {
|
||||
/** Laufzeitbeginn laut Vertrag. */
|
||||
beginnAm: Date | null
|
||||
/** Fest vereinbartes Vertragsende, falls bekannt. */
|
||||
endeAm?: Date | null
|
||||
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
|
||||
laufzeitJahre?: number | null
|
||||
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
|
||||
kuendigungsfristMonate: number | null
|
||||
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
|
||||
verlaengertSichAutomatisch?: boolean
|
||||
/** Bezugstag; im Test gesetzt, sonst heute. */
|
||||
heute?: Date
|
||||
}
|
||||
|
||||
export type Kuendigungsplan = {
|
||||
/** Laesst sich ueberhaupt etwas berechnen? */
|
||||
berechenbar: boolean
|
||||
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
|
||||
naechsterTermin: Date | null
|
||||
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
|
||||
zugangSpaetestensAm: Date | null
|
||||
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
|
||||
spaetesterAbsendetag: Date | null
|
||||
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
|
||||
tageBisAbsendetag: number | null
|
||||
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
|
||||
vorwarnung: boolean
|
||||
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
|
||||
verpasst: boolean
|
||||
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
const LEERER_PLAN: Kuendigungsplan = {
|
||||
berechenbar: false,
|
||||
naechsterTermin: null,
|
||||
zugangSpaetestensAm: null,
|
||||
spaetesterAbsendetag: null,
|
||||
tageBisAbsendetag: null,
|
||||
vorwarnung: false,
|
||||
verpasst: false,
|
||||
hinweise: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den naechstmoeglichen Kuendigungstermin.
|
||||
*
|
||||
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
|
||||
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
|
||||
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
|
||||
* rausgeht, ist der naechstmoegliche.
|
||||
*/
|
||||
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
|
||||
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
|
||||
const hinweise: string[] = []
|
||||
|
||||
const frist = eingabe.kuendigungsfristMonate
|
||||
if (frist === null || frist === undefined) {
|
||||
hinweise.push(
|
||||
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
|
||||
'bitte im Vertrag nachsehen und nachtragen.',
|
||||
)
|
||||
}
|
||||
const fristMonate = frist ?? 0
|
||||
|
||||
const laufzeit =
|
||||
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
|
||||
? eingabe.laufzeitJahre
|
||||
: STANDARD_LAUFZEIT_JAHRE
|
||||
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
|
||||
hinweise.push(
|
||||
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
|
||||
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
|
||||
)
|
||||
}
|
||||
|
||||
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
|
||||
|
||||
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
|
||||
let termin: Date | null = null
|
||||
if (eingabe.endeAm) {
|
||||
termin = tagesdatum(eingabe.endeAm)
|
||||
} else if (eingabe.beginnAm) {
|
||||
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
|
||||
}
|
||||
|
||||
if (!termin) {
|
||||
return {
|
||||
...LEERER_PLAN,
|
||||
hinweise: [
|
||||
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
|
||||
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
|
||||
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
|
||||
let absendetag = absendetagZu(termin, fristMonate)
|
||||
let durchlaeufe = 0
|
||||
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
|
||||
termin = plusJahre(termin, laufzeit)
|
||||
absendetag = absendetagZu(termin, fristMonate)
|
||||
durchlaeufe += 1
|
||||
}
|
||||
|
||||
const zugang = plusMonate(termin, -fristMonate)
|
||||
const tageBis = tageZwischen(heute, absendetag)
|
||||
const verpasst = absendetag.getTime() < heute.getTime()
|
||||
|
||||
if (verpasst) {
|
||||
hinweise.push(
|
||||
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
|
||||
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
|
||||
`Termin ist nicht mehr möglich.`,
|
||||
)
|
||||
} else if (durchlaeufe > 0) {
|
||||
hinweise.push(
|
||||
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
|
||||
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
|
||||
)
|
||||
}
|
||||
|
||||
hinweise.push(
|
||||
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
|
||||
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
|
||||
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
|
||||
`ein Fax mit Sendeprotokoll.`,
|
||||
)
|
||||
|
||||
return {
|
||||
berechenbar: true,
|
||||
naechsterTermin: termin,
|
||||
zugangSpaetestensAm: zugang,
|
||||
spaetesterAbsendetag: absendetag,
|
||||
tageBisAbsendetag: tageBis,
|
||||
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
|
||||
verpasst,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
|
||||
export function absendetagZu(termin: Date, fristMonate: number): Date {
|
||||
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
|
||||
* Farbe ist nie die einzige Information - der Text steht daneben.
|
||||
*/
|
||||
export type Kuendigungsampel = {
|
||||
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
|
||||
text: string
|
||||
}
|
||||
|
||||
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
|
||||
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
|
||||
if (plan.verpasst) {
|
||||
return { variante: 'rot', text: 'Absendetag verstrichen' }
|
||||
}
|
||||
const tage = plan.tageBisAbsendetag
|
||||
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
|
||||
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
|
||||
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
|
||||
}
|
||||
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
|
||||
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
|
||||
*
|
||||
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
|
||||
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
|
||||
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
|
||||
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
|
||||
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
|
||||
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
|
||||
* Spanne unter einem halben Jahr, bleibt es beim Standard.
|
||||
*/
|
||||
export function laufzeitAusVertrag(
|
||||
beginnAm: Date | null,
|
||||
endeAm: Date | null,
|
||||
): number | null {
|
||||
if (!beginnAm || !endeAm) return null
|
||||
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
|
||||
return jahre >= 1 ? jahre : null
|
||||
}
|
||||
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
|
||||
|
||||
/**
|
||||
* Kuendigungsfrist-Rechner.
|
||||
*
|
||||
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
|
||||
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
|
||||
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
|
||||
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
|
||||
* verpasst und zahlt ein weiteres Jahr.
|
||||
*
|
||||
* Deshalb rechnet diese Datei drei Daten aus:
|
||||
*
|
||||
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
|
||||
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
|
||||
* 3. den spaetesten Absendetag mit Postlaufpuffer.
|
||||
*
|
||||
* Reine Logik, keine Datenbank.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
|
||||
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
|
||||
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
|
||||
* Zugang und ist am selben Tag da.
|
||||
*/
|
||||
export const POSTLAUF_PUFFER_TAGE = 3
|
||||
|
||||
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
|
||||
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
|
||||
|
||||
/**
|
||||
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
|
||||
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
|
||||
*/
|
||||
export const STANDARD_LAUFZEIT_JAHRE = 1
|
||||
|
||||
export type KuendigungEingabe = {
|
||||
/** Laufzeitbeginn laut Vertrag. */
|
||||
beginnAm: Date | null
|
||||
/** Fest vereinbartes Vertragsende, falls bekannt. */
|
||||
endeAm?: Date | null
|
||||
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
|
||||
laufzeitJahre?: number | null
|
||||
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
|
||||
kuendigungsfristMonate: number | null
|
||||
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
|
||||
verlaengertSichAutomatisch?: boolean
|
||||
/** Bezugstag; im Test gesetzt, sonst heute. */
|
||||
heute?: Date
|
||||
}
|
||||
|
||||
export type Kuendigungsplan = {
|
||||
/** Laesst sich ueberhaupt etwas berechnen? */
|
||||
berechenbar: boolean
|
||||
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
|
||||
naechsterTermin: Date | null
|
||||
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
|
||||
zugangSpaetestensAm: Date | null
|
||||
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
|
||||
spaetesterAbsendetag: Date | null
|
||||
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
|
||||
tageBisAbsendetag: number | null
|
||||
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
|
||||
vorwarnung: boolean
|
||||
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
|
||||
verpasst: boolean
|
||||
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
const LEERER_PLAN: Kuendigungsplan = {
|
||||
berechenbar: false,
|
||||
naechsterTermin: null,
|
||||
zugangSpaetestensAm: null,
|
||||
spaetesterAbsendetag: null,
|
||||
tageBisAbsendetag: null,
|
||||
vorwarnung: false,
|
||||
verpasst: false,
|
||||
hinweise: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den naechstmoeglichen Kuendigungstermin.
|
||||
*
|
||||
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
|
||||
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
|
||||
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
|
||||
* rausgeht, ist der naechstmoegliche.
|
||||
*/
|
||||
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
|
||||
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
|
||||
const hinweise: string[] = []
|
||||
|
||||
const frist = eingabe.kuendigungsfristMonate
|
||||
if (frist === null || frist === undefined) {
|
||||
hinweise.push(
|
||||
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
|
||||
'bitte im Vertrag nachsehen und nachtragen.',
|
||||
)
|
||||
}
|
||||
const fristMonate = frist ?? 0
|
||||
|
||||
const laufzeit =
|
||||
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
|
||||
? eingabe.laufzeitJahre
|
||||
: STANDARD_LAUFZEIT_JAHRE
|
||||
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
|
||||
hinweise.push(
|
||||
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
|
||||
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
|
||||
)
|
||||
}
|
||||
|
||||
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
|
||||
|
||||
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
|
||||
let termin: Date | null = null
|
||||
if (eingabe.endeAm) {
|
||||
termin = tagesdatum(eingabe.endeAm)
|
||||
} else if (eingabe.beginnAm) {
|
||||
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
|
||||
}
|
||||
|
||||
if (!termin) {
|
||||
return {
|
||||
...LEERER_PLAN,
|
||||
hinweise: [
|
||||
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
|
||||
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
|
||||
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
|
||||
let absendetag = absendetagZu(termin, fristMonate)
|
||||
let durchlaeufe = 0
|
||||
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
|
||||
termin = plusJahre(termin, laufzeit)
|
||||
absendetag = absendetagZu(termin, fristMonate)
|
||||
durchlaeufe += 1
|
||||
}
|
||||
|
||||
const zugang = plusMonate(termin, -fristMonate)
|
||||
const tageBis = tageZwischen(heute, absendetag)
|
||||
const verpasst = absendetag.getTime() < heute.getTime()
|
||||
|
||||
if (verpasst) {
|
||||
hinweise.push(
|
||||
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
|
||||
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
|
||||
`Termin ist nicht mehr möglich.`,
|
||||
)
|
||||
} else if (durchlaeufe > 0) {
|
||||
hinweise.push(
|
||||
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
|
||||
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
|
||||
)
|
||||
}
|
||||
|
||||
hinweise.push(
|
||||
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
|
||||
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
|
||||
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
|
||||
`ein Fax mit Sendeprotokoll.`,
|
||||
)
|
||||
|
||||
return {
|
||||
berechenbar: true,
|
||||
naechsterTermin: termin,
|
||||
zugangSpaetestensAm: zugang,
|
||||
spaetesterAbsendetag: absendetag,
|
||||
tageBisAbsendetag: tageBis,
|
||||
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
|
||||
verpasst,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
|
||||
export function absendetagZu(termin: Date, fristMonate: number): Date {
|
||||
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
|
||||
* Farbe ist nie die einzige Information - der Text steht daneben.
|
||||
*/
|
||||
export type Kuendigungsampel = {
|
||||
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
|
||||
text: string
|
||||
}
|
||||
|
||||
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
|
||||
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
|
||||
if (plan.verpasst) {
|
||||
return { variante: 'rot', text: 'Absendetag verstrichen' }
|
||||
}
|
||||
const tage = plan.tageBisAbsendetag
|
||||
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
|
||||
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
|
||||
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
|
||||
}
|
||||
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
|
||||
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
|
||||
*
|
||||
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
|
||||
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
|
||||
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
|
||||
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
|
||||
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
|
||||
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
|
||||
* Spanne unter einem halben Jahr, bleibt es beim Standard.
|
||||
*/
|
||||
export function laufzeitAusVertrag(
|
||||
beginnAm: Date | null,
|
||||
endeAm: Date | null,
|
||||
): number | null {
|
||||
if (!beginnAm || !endeAm) return null
|
||||
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
|
||||
return jahre >= 1 ? jahre : null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user