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
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user