Auf dem Dashboard standen dauerhaft drei rote Kaesten mit Warndreieck und Knopf "Frist jetzt bearbeiten": Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Alle anderen Bloecke des Dashboards filtern nach Dringlichkeit. Dieser fragte nur, OB etwas eine Ausschlussfrist ist, und nie, WANN sie faellig wird - eine Frist im Mai 2029 landete damit in derselben Aufmachung wie eine, die morgen ablaeuft. Die Daten sind richtig: Pflichtfristen werden bewusst drei Jahre im Voraus angelegt. Sie gehoeren nur nicht in die Warnung, sondern in die Liste. Neu ist ein Horizont von 90 Tagen. Er ist grosszuegig gewaehlt, weil die kuerzeste hier vorkommende Ausschlussfrist sechs Monate laesst. Abgelaufene warnen weiterhin immer - eine versaeumte Aufwandspauschale bleibt eine schlechte Nachricht, auch wenn sie lange zurueckliegt. Der Grund ist nicht Bequemlichkeit: Eine Warnung, die monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht die vierte, die etwas will - und bei § 1878 BGB ist das die Jahrespauschale, die ersatzlos erlischt. Ein bestehender Test behauptete "unabhaengig vom Faelligkeitstopf" und belegte das mit einer Frist 134 Tage in der Zukunft. Die Aussage stimmt weiterhin, das Beispiel nicht mehr - es liegt jetzt innerhalb des Horizonts, aber ausserhalb aller Faelligkeitstoepfe. Gegen drei Mutationen geprueft: Filter entfernt, Abgelaufenes ausgeschlossen, Horizont um einen Tag verschoben - jede wird rot. Refs #40
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import { differenceInCalendarDays, endOfMonth, endOfWeek, startOfDay } from 'date-fns'
|
||
import type { FristTyp, NachforderungStatus, VermoegensArt, VorgangKategorie } from '@prisma/client'
|
||
import type {
|
||
Ampelstufe,
|
||
FristEintrag,
|
||
FristenAmpelDaten,
|
||
NachforderungEintrag,
|
||
NachforderungenDaten,
|
||
WartendEintrag,
|
||
WartetAufAndereDaten,
|
||
} from '@/lib/dashboard/typen'
|
||
|
||
/**
|
||
* Die Rechenregeln hinter dem Dashboard - ohne Datenbank, ohne React.
|
||
*
|
||
* Alles, was hier steht, laesst sich in tests/dashboard.test.ts pruefen.
|
||
* laden.ts holt nur die Datensaetze und reicht sie hier durch; die
|
||
* Bewertung "ist das dringend?" faellt an genau einer Stelle.
|
||
*/
|
||
|
||
/** Ab so vielen Tagen ohne Rueckmeldung wird eine Sachstandsanfrage faellig. */
|
||
export const WARTEZEIT_DRINGEND_TAGE = 21
|
||
|
||
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
|
||
const AUSSCHLUSSFRISTEN_MAX = 3
|
||
|
||
/**
|
||
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
|
||
*
|
||
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
|
||
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
|
||
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
|
||
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
|
||
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
|
||
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
|
||
*
|
||
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
|
||
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
|
||
* die vierte, die etwas will - und bei § 1878 BGB ist das die
|
||
* Jahrespauschale, die ersatzlos erlischt.
|
||
*
|
||
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
|
||
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
|
||
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
|
||
*
|
||
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
|
||
* weiterhin in der Fristenliste.
|
||
*/
|
||
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
|
||
|
||
/**
|
||
* Gehoert diese Frist in den hervorgehobenen Warnblock?
|
||
*
|
||
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
|
||
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
|
||
* innerhalb des Horizonts faellig wird.
|
||
*/
|
||
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
|
||
if (!eintrag.ausschlussfrist) return false
|
||
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
|
||
}
|
||
|
||
/**
|
||
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
|
||
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
|
||
* verschiebt eine Frist um einen Tag. Deshalb: auf den lokalen Tagesanfang
|
||
* ziehen, und zwar anhand der UTC-Bestandteile.
|
||
*/
|
||
export function tagAusDatenbank(datum: Date): Date {
|
||
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate())
|
||
}
|
||
|
||
/** Tage von heute bis zum Datum. Negativ = liegt in der Vergangenheit. */
|
||
export function tageBisTag(datum: Date, heute: Date): number {
|
||
return differenceInCalendarDays(datum, startOfDay(heute))
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 1. Fristen-Ampel
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type FristRoh = {
|
||
id: string
|
||
titel: string
|
||
datum: Date
|
||
typ: FristTyp
|
||
rechtsgrundlage: string | null
|
||
ausschlussfrist: boolean
|
||
vorgangId: string | null
|
||
}
|
||
|
||
/**
|
||
* Konventionen aus docs/KONVENTIONEN.md:
|
||
* rot = ueberfaellig, orange = diese Woche, gelb = dieser Monat,
|
||
* gruen = weiter in der Zukunft.
|
||
*
|
||
* "Diese Woche" endet am Sonntag, "dieser Monat" am Monatsletzten - nicht
|
||
* "in 7 Tagen" bzw. "in 30 Tagen". Wer montags auf das Dashboard schaut,
|
||
* will wissen, was bis Sonntag liegen bleibt.
|
||
*/
|
||
export function fristStufe(datum: Date, heute: Date): Ampelstufe {
|
||
const tag = startOfDay(datum)
|
||
const heuteTag = startOfDay(heute)
|
||
if (tag < heuteTag) return 'rot'
|
||
if (tag <= endOfWeek(heuteTag, { weekStartsOn: 1 })) return 'orange'
|
||
if (tag <= endOfMonth(heuteTag)) return 'gelb'
|
||
return 'gruen'
|
||
}
|
||
|
||
/** "seit 3 Tagen ueberfaellig" - der Text, der neben der Farbe stehen muss. */
|
||
export function fristLage(tage: number): string {
|
||
if (tage < -1) return `seit ${Math.abs(tage)} Tagen überfällig`
|
||
if (tage === -1) return 'seit gestern überfällig'
|
||
if (tage === 0) return 'heute fällig'
|
||
if (tage === 1) return 'morgen fällig'
|
||
return `in ${tage} Tagen fällig`
|
||
}
|
||
|
||
/**
|
||
* Die Verfallwarnung. Eine Ausschlussfrist kennt keine Nachsicht: laeuft
|
||
* sie ab, ist der Anspruch weg - bei der Aufwandspauschale nach § 1878 BGB
|
||
* z.B. das ganze Jahresgeld.
|
||
*/
|
||
export function verfallwarnung(frist: { tage: number; rechtsgrundlage: string | null }): string {
|
||
const grundlage = frist.rechtsgrundlage ? `${frist.rechtsgrundlage}: ` : ''
|
||
if (frist.tage < 0) {
|
||
const tage = Math.abs(frist.tage)
|
||
return (
|
||
`${grundlage}Diese Ausschlussfrist ist seit ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} ` +
|
||
`abgelaufen. Läuft der Anspruch ab, ist er ersatzlos weg. Bitte umgehend beim Gericht ` +
|
||
`bzw. bei der Behörde nachfragen.`
|
||
)
|
||
}
|
||
return (
|
||
`${grundlage}Ausschlussfrist – keine Verlängerung, keine Nachfrist. ` +
|
||
`Läuft der Anspruch ab, ist er ersatzlos weg.`
|
||
)
|
||
}
|
||
|
||
export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpelDaten {
|
||
const eintraege: FristEintrag[] = fristen.map((frist) => {
|
||
const datum = tagAusDatenbank(frist.datum)
|
||
const tage = tageBisTag(datum, heute)
|
||
return {
|
||
id: frist.id,
|
||
titel: frist.titel,
|
||
datum,
|
||
typ: frist.typ,
|
||
rechtsgrundlage: frist.rechtsgrundlage,
|
||
ausschlussfrist: frist.ausschlussfrist,
|
||
vorgangId: frist.vorgangId,
|
||
tage,
|
||
stufe: fristStufe(datum, heute),
|
||
}
|
||
})
|
||
|
||
// Frueheste zuerst - damit steht die aelteste ueberfaellige ganz oben.
|
||
eintraege.sort((a, b) => a.datum.getTime() - b.datum.getTime())
|
||
|
||
return {
|
||
ueberfaellig: eintraege.filter((e) => e.stufe === 'rot'),
|
||
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
|
||
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
|
||
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
|
||
ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
|
||
gesamtOffen: eintraege.length,
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 2. Wartet auf andere
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type WartendRoh = {
|
||
id: string
|
||
titel: string
|
||
kategorie: VorgangKategorie
|
||
gegenstelle: string | null
|
||
aktenzeichen: string | null
|
||
naechsterSchritt: string | null
|
||
letzterKontaktAm: Date | null
|
||
erstelltAm: Date
|
||
}
|
||
|
||
/**
|
||
* Sortiert nach Tagen seit dem letzten Kontakt, laengste Wartezeit zuerst.
|
||
* Ist nie ein Kontakt vermerkt worden, zaehlt das Anlegedatum - sonst
|
||
* rutschte ausgerechnet der Vorgang nach unten, um den sich noch nie
|
||
* jemand gekuemmert hat.
|
||
*/
|
||
export function wartendeEinordnen(vorgaenge: WartendRoh[], heute: Date): WartetAufAndereDaten {
|
||
const eintraege: WartendEintrag[] = vorgaenge.map((vorgang) => {
|
||
const bezug = vorgang.letzterKontaktAm
|
||
? tagAusDatenbank(vorgang.letzterKontaktAm)
|
||
: startOfDay(vorgang.erstelltAm)
|
||
const tageOhneRueckmeldung = Math.max(0, -tageBisTag(bezug, heute))
|
||
|
||
return {
|
||
id: vorgang.id,
|
||
titel: vorgang.titel,
|
||
kategorie: vorgang.kategorie,
|
||
gegenstelle: vorgang.gegenstelle,
|
||
aktenzeichen: vorgang.aktenzeichen,
|
||
naechsterSchritt: vorgang.naechsterSchritt,
|
||
letzterKontaktAm: vorgang.letzterKontaktAm
|
||
? tagAusDatenbank(vorgang.letzterKontaktAm)
|
||
: null,
|
||
tageOhneRueckmeldung,
|
||
dringend: tageOhneRueckmeldung >= WARTEZEIT_DRINGEND_TAGE,
|
||
}
|
||
})
|
||
|
||
eintraege.sort((a, b) => b.tageOhneRueckmeldung - a.tageOhneRueckmeldung)
|
||
|
||
return {
|
||
eintraege,
|
||
gesamt: eintraege.length,
|
||
dringende: eintraege.filter((e) => e.dringend).length,
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 3. Nachforderungen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/** Diese Zustaende sind aus meiner Sicht abgearbeitet. */
|
||
const NACHFORDERUNG_ERLEDIGT: NachforderungStatus[] = [
|
||
'UEBERSANDT',
|
||
'ANERKANNT',
|
||
'NICHT_VORHANDEN',
|
||
]
|
||
|
||
export type NachforderungRoh = {
|
||
id: string
|
||
bezeichnung: string
|
||
status: NachforderungStatus
|
||
fristBis: Date | null
|
||
reihenfolge: number
|
||
antragId: string
|
||
antragTraeger: string | null
|
||
antragAktenzeichen: string | null
|
||
}
|
||
|
||
export function nachforderungenAuswerten(
|
||
zeilen: NachforderungRoh[],
|
||
heute: Date,
|
||
): NachforderungenDaten {
|
||
const offenRoh = zeilen.filter((z) => !NACHFORDERUNG_ERLEDIGT.includes(z.status))
|
||
|
||
const offen: NachforderungEintrag[] = offenRoh.map((zeile) => {
|
||
const fristBis = zeile.fristBis ? tagAusDatenbank(zeile.fristBis) : null
|
||
return {
|
||
id: zeile.id,
|
||
bezeichnung: zeile.bezeichnung,
|
||
status: zeile.status,
|
||
fristBis,
|
||
tageBisFrist: fristBis ? tageBisTag(fristBis, heute) : null,
|
||
}
|
||
})
|
||
|
||
// Zuerst, was eine Frist hat, und davon das Dringendste.
|
||
offen.sort((a, b) => {
|
||
if (a.tageBisFrist === null && b.tageBisFrist === null) return 0
|
||
if (a.tageBisFrist === null) return 1
|
||
if (b.tageBisFrist === null) return -1
|
||
return a.tageBisFrist - b.tageBisFrist
|
||
})
|
||
|
||
const erster = zeilen[0]
|
||
|
||
return {
|
||
antragId: erster?.antragId ?? null,
|
||
traeger: erster?.antragTraeger ?? null,
|
||
aktenzeichen: erster?.antragAktenzeichen ?? null,
|
||
gesamt: zeilen.length,
|
||
erledigt: zeilen.filter((z) => NACHFORDERUNG_ERLEDIGT.includes(z.status)).length,
|
||
offen,
|
||
ueberfaellig: offen.filter((o) => o.tageBisFrist !== null && o.tageBisFrist < 0).length,
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 6. Kennzahlen: Vermoegen und Heimkosten
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type PostenRoh = {
|
||
id: string
|
||
art: VermoegensArt
|
||
wertstaende: { stichtag: Date; wertCent: bigint }[]
|
||
}
|
||
|
||
export type VermoegenslageRoh = {
|
||
stichtag: Date | null
|
||
aktivaCent: bigint
|
||
verbindlichkeitenCent: bigint
|
||
nettoCent: bigint
|
||
postenGesamt: number
|
||
postenOhneWert: number
|
||
}
|
||
|
||
/**
|
||
* Vermoegen zum letzten Stichtag: je Posten zaehlt der juengste Wertstand,
|
||
* Verbindlichkeiten werden abgezogen.
|
||
*
|
||
* Der ausgewiesene Stichtag ist der juengste tatsaechlich verwendete - die
|
||
* Posten koennen zu unterschiedlichen Terminen bewertet worden sein, und
|
||
* genau das muss die Kachel sagen duerfen.
|
||
*/
|
||
export function vermoegenBerechnen(posten: PostenRoh[]): VermoegenslageRoh {
|
||
let aktivaCent = 0n
|
||
let verbindlichkeitenCent = 0n
|
||
let stichtag: Date | null = null
|
||
let postenOhneWert = 0
|
||
|
||
for (const p of posten) {
|
||
let juengster: { stichtag: Date; wertCent: bigint } | null = null
|
||
for (const wert of p.wertstaende) {
|
||
if (juengster === null || wert.stichtag.getTime() > juengster.stichtag.getTime()) {
|
||
juengster = wert
|
||
}
|
||
}
|
||
|
||
if (juengster === null) {
|
||
postenOhneWert += 1
|
||
continue
|
||
}
|
||
|
||
if (p.art === 'VERBINDLICHKEIT') {
|
||
verbindlichkeitenCent += juengster.wertCent
|
||
} else {
|
||
aktivaCent += juengster.wertCent
|
||
}
|
||
|
||
const tag = tagAusDatenbank(juengster.stichtag)
|
||
if (stichtag === null || tag.getTime() > stichtag.getTime()) stichtag = tag
|
||
}
|
||
|
||
return {
|
||
stichtag,
|
||
aktivaCent,
|
||
verbindlichkeitenCent,
|
||
nettoCent: aktivaCent - verbindlichkeitenCent,
|
||
postenGesamt: posten.length,
|
||
postenOhneWert,
|
||
}
|
||
}
|
||
|
||
export type HeimkostenmonatRoh = {
|
||
jahr: number
|
||
monat: number
|
||
pflegeEigenanteilCent: bigint
|
||
unterkunftVerpflegungCent: bigint
|
||
investitionskostenCent: bigint
|
||
ausbildungsumlageCent: bigint
|
||
zusatzleistungenCent: bigint
|
||
pflegekasseCent: bigint
|
||
leistungszuschlagCent: bigint
|
||
renteCent: bigint
|
||
sonstigeEinkuenfteCent: bigint
|
||
sozialhilfeCent: bigint
|
||
eigenmittelCent: bigint
|
||
}
|
||
|
||
export type HeimkostenlageRoh = {
|
||
jahr: number
|
||
monat: number
|
||
gesamtkostenCent: bigint
|
||
deckungCent: bigint
|
||
deckungsluecke: bigint
|
||
}
|
||
|
||
/**
|
||
* Deckungsluecke = Kostenseite minus Deckungsseite, genau nach der
|
||
* Gruppierung im Schema. Bleibt etwas uebrig, muss es jemand zahlen - das
|
||
* ist die Zahl, mit der ich beim Bezirk vorstellig werde.
|
||
*/
|
||
export function heimkostenlageBerechnen(monat: HeimkostenmonatRoh): HeimkostenlageRoh {
|
||
const gesamtkostenCent =
|
||
monat.pflegeEigenanteilCent +
|
||
monat.unterkunftVerpflegungCent +
|
||
monat.investitionskostenCent +
|
||
monat.ausbildungsumlageCent +
|
||
monat.zusatzleistungenCent
|
||
|
||
const deckungCent =
|
||
monat.pflegekasseCent +
|
||
monat.leistungszuschlagCent +
|
||
monat.renteCent +
|
||
monat.sonstigeEinkuenfteCent +
|
||
monat.sozialhilfeCent +
|
||
monat.eigenmittelCent
|
||
|
||
return {
|
||
jahr: monat.jahr,
|
||
monat: monat.monat,
|
||
gesamtkostenCent,
|
||
deckungCent,
|
||
deckungsluecke: gesamtkostenCent - deckungCent,
|
||
}
|
||
}
|
||
|
||
export const MONATSNAMEN = [
|
||
'Januar',
|
||
'Februar',
|
||
'März',
|
||
'April',
|
||
'Mai',
|
||
'Juni',
|
||
'Juli',
|
||
'August',
|
||
'September',
|
||
'Oktober',
|
||
'November',
|
||
'Dezember',
|
||
] as const
|
||
|
||
export function monatsname(monat: number): string {
|
||
return MONATSNAMEN[monat - 1] ?? String(monat)
|
||
}
|