Files
betreuungs-cockpit/src/lib/fristen/wiederholung.ts
T
Florian Wilde 22b3a9b9e9 feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
2026-08-22 02:16:22 +02:00

190 lines
6.7 KiB
TypeScript

import { Frequency, RRule } from 'rrule'
import { tagesdatum } from '@/lib/fristen/datum'
/**
* Wiederkehrende Fristen.
*
* Die Wiederholung steht als iCalendar-RRULE an der Frist
* ("FREQ=YEARLY;INTERVAL=1"). Gespeichert wird immer nur die naechste
* faellige Frist, nicht eine Reihe im Voraus - erst beim Erledigen entsteht
* die naechste. Sonst haette man nach fuenf Jahren eine Liste voller
* Termine, die nie jemand angefasst hat.
*
* Die neue Frist zeigt ueber vorgaengerFristId auf die erledigte. Damit
* laesst sich die Kette rueckwaerts lesen: "wann habe ich das zuletzt
* gemacht?" ist gegenueber dem Gericht eine haeufige Frage.
*
* Achtung, bewusste Entscheidung: Die abgeleiteten Pflichtfristen aus
* pflichtfristen.ts tragen KEINE RRULE. Sie werden aus dem Bestellungsdatum
* neu berechnet. Haetten sie zusaetzlich eine RRULE, entstuende beim
* Erledigen eine zweite Frist neben der ohnehin abgeleiteten - eine
* Dublette, die der eindeutige systemSchluessel nicht abfaengt, weil die
* Nachfolgefrist keinen Systemschluessel bekommt.
*/
export type FristFuerWiederholung = {
id: string
betreuteId: string
vorgangId?: string | null
titel: string
beschreibung?: string | null
datum: Date
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
rechtsgrundlage?: string | null
vorwarnungTage: number[]
wiederholungRrule?: string | null
ausschlussfrist: boolean
}
/** Bauplan der Nachfolgefrist, fertig fuer db.frist.create({ data }). */
export type Nachfolgefrist = {
betreuteId: string
vorgangId: string | null
titel: string
beschreibung: string | null
datum: Date
typ: FristFuerWiederholung['typ']
rechtsgrundlage: string | null
vorwarnungTage: number[]
wiederholungRrule: string
ausschlussfrist: boolean
vorgaengerFristId: string
}
/**
* Baut die Regel neu auf und setzt DTSTART auf das uebergebene Datum.
*
* Ein in der Regel mitgeliefertes DTSTART wird dabei absichtlich verworfen:
* Massgeblich ist immer der Termin der zuletzt erledigten Frist, sonst
* wandert eine monatliche Wiedervorlage nach dem ersten Verschieben wieder
* auf das urspruengliche Raster zurueck.
*/
function regelMitStart(regel: string, dtstart: Date): RRule {
const nurRrule = regel
.split(/\r?\n/)
.map((zeile) => zeile.trim())
.filter((zeile) => zeile !== '' && !/^DTSTART/i.test(zeile))
.join('\n')
.replace(/^RRULE:/i, '')
const geparst = RRule.parseString(nurRrule)
if (geparst.freq === undefined || geparst.freq === null) {
throw new Error('Die Wiederholungsregel enthält kein FREQ.')
}
// rrule rechnet ohne TZID in UTC. Da unsere Fristen ohnehin auf
// UTC-Mitternacht liegen, passt das genau zusammen.
return new RRule({ ...geparst, dtstart: tagesdatum(dtstart) })
}
/** Ist die Regel ueberhaupt lesbar? Fuer die Formularpruefung. */
export function rruleGueltig(regel: string): boolean {
try {
regelMitStart(regel, new Date(Date.UTC(2026, 0, 1)))
return true
} catch {
return false
}
}
/**
* Der naechste Termin nach `nachDatum` - der Tag selbst zaehlt nicht mit,
* sonst bekaeme man beim Erledigen dieselbe Frist noch einmal.
* Liefert null, wenn die Regel endet (COUNT/UNTIL erschoepft).
*/
export function naechsterTermin(regel: string, nachDatum: Date): Date | null {
let rule: RRule
try {
rule = regelMitStart(regel, nachDatum)
} catch {
return null
}
const treffer = rule.after(tagesdatum(nachDatum), false)
return treffer ? tagesdatum(treffer) : null
}
/**
* Der Bauplan der Frist, die beim Erledigen einer wiederkehrenden Frist
* entsteht. Null, wenn die Frist nicht wiederkehrt oder die Regel
* ausgelaufen ist.
*
* Gerechnet wird ab dem Solltermin der erledigten Frist, nicht ab dem Tag
* der Erledigung: Wer den Bericht drei Tage zu spaet abgibt, soll den
* naechsten trotzdem wieder zum Stichtag abgeben und nicht dauerhaft drei
* Tage nach hinten rutschen.
*/
export function naechsteFristAusErledigter(frist: FristFuerWiederholung): Nachfolgefrist | null {
if (!frist.wiederholungRrule) return null
const naechstes = naechsterTermin(frist.wiederholungRrule, frist.datum)
if (!naechstes) return null
return {
betreuteId: frist.betreuteId,
vorgangId: frist.vorgangId ?? null,
titel: frist.titel,
beschreibung: frist.beschreibung ?? null,
datum: naechstes,
typ: frist.typ,
rechtsgrundlage: frist.rechtsgrundlage ?? null,
vorwarnungTage: frist.vorwarnungTage,
wiederholungRrule: frist.wiederholungRrule,
ausschlussfrist: frist.ausschlussfrist,
vorgaengerFristId: frist.id,
}
}
// -----------------------------------------------------------------------------
// Auswahl fuer die Oberflaeche
// -----------------------------------------------------------------------------
/**
* Die Wiederholungen, die im Betreueralltag tatsaechlich vorkommen. Ein
* freies RRULE-Eingabefeld waere fuer die Zielgruppe eine Zumutung; wer
* eine exotische Regel braucht, kann sie ueber "Eigene Regel" setzen.
*/
export const WIEDERHOLUNGEN: { wert: string; beschriftung: string }[] = [
{ wert: '', beschriftung: 'Einmalig — wiederholt sich nicht' },
{ wert: 'FREQ=WEEKLY;INTERVAL=1', beschriftung: 'Wöchentlich' },
{ wert: 'FREQ=WEEKLY;INTERVAL=2', beschriftung: 'Alle zwei Wochen' },
{ wert: 'FREQ=MONTHLY;INTERVAL=1', beschriftung: 'Monatlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=3', beschriftung: 'Vierteljährlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=6', beschriftung: 'Halbjährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=1', beschriftung: 'Jährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=2', beschriftung: 'Alle zwei Jahre' },
]
const FREQUENZ_TEXT: Record<number, [string, string]> = {
[Frequency.DAILY]: ['Täglich', 'Tage'],
[Frequency.WEEKLY]: ['Wöchentlich', 'Wochen'],
[Frequency.MONTHLY]: ['Monatlich', 'Monate'],
[Frequency.YEARLY]: ['Jährlich', 'Jahre'],
}
/** Deutsche Klartextbeschreibung einer Regel, z.B. "Alle 3 Monate". */
export function rruleBeschreiben(regel: string | null | undefined): string {
if (!regel) return 'Einmalig'
const bekannt = WIEDERHOLUNGEN.find((w) => w.wert === regel)
if (bekannt && bekannt.wert !== '') return bekannt.beschriftung
try {
const geparst = RRule.parseString(regel.replace(/^RRULE:/i, ''))
const freq = geparst.freq
if (freq === undefined || freq === null) return 'Wiederkehrend'
const text = FREQUENZ_TEXT[freq]
if (!text) return 'Wiederkehrend'
const intervall = geparst.interval ?? 1
const grund = intervall === 1 ? text[0] : `Alle ${intervall} ${text[1]}`
if (geparst.count) return `${grund}, ${geparst.count}-mal`
if (geparst.until) {
return `${grund}, bis ${tagesdatum(geparst.until).toLocaleDateString('de-DE', { timeZone: 'UTC' })}`
}
return grund
} catch {
return 'Wiederkehrend'
}
}