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) }