Files
betreuungs-cockpit/src/lib/dashboard/bewertung.ts
T
Florian Wilde b3823094d6 Ausschlussfristen nur hervorheben, wenn sie etwas von einem wollen
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
2026-09-01 15:50:17 +02:00

420 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}