Fix #26: Zeilenenden im Repository auf LF #36

Merged
florian.wilde merged 2 commits from fix/26-zeilenenden into main 2026-08-23 22:28:47 +00:00
25 changed files with 7480 additions and 7448 deletions
+32
View File
@@ -0,0 +1,32 @@
# Zeilenenden
#
# Im Repository steht ueberall LF, unabhaengig davon, auf welchem System
# gearbeitet wird. Der Grund ist nicht Geschmack, sondern eine Falle:
#
# Solange Dateien gemischt vorliegen, schreibt jedes zeilenweise
# arbeitende Werkzeug - sed, ein Formatierer, ein Skript - die Datei mit
# LF zurueck. Der Diff zeigt dann nicht die drei geaenderten Zeilen,
# sondern die ganze Datei. Genau das ist passiert: 578 Diff-Zeilen statt
# 8, kurz vor einem Push bemerkt.
#
# Nebenwirkung derselben Mischung: "git diff --check" meldet bei
# CRLF-Dateien jede hinzugefuegte Zeile als "trailing whitespace", weil
# es das CR so wertet. Damit ist die Pruefung fuer diese Dateien wertlos.
* text=auto eol=lf
# Shell-Skripte laufen auf dem Server. Ein CRLF im Shebang macht sie
# unbrauchbar ("bad interpreter"), und der Fehler ist von aussen nicht
# anzusehen. Deshalb hier ausdruecklich, nicht nur ueber die Regel oben.
*.sh text eol=lf
# Binaerdateien nie anfassen.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.zip binary
+187 -187
View File
@@ -1,187 +1,187 @@
# Schema-Wünsche aus Etappe 3 „Papier“ # Schema-Wünsche aus Etappe 3 „Papier“
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
funktioniert, wäre aber sauberer, wenn es im Modell stünde. funktioniert, wäre aber sauberer, wenn es im Modell stünde.
Reihenfolge: was am meisten weh tut, steht oben. Reihenfolge: was am meisten weh tut, steht oben.
--- ---
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen ## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt **Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau „Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
Prüfung in `dokumentVerknuepfen`). Prüfung in `dokumentVerknuepfen`).
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein **Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
Seed oder ein späterer Import das nicht verletzen kann: Seed oder ein späterer Import das nicht verletzen kann:
```sql ```sql
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int + ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1 + ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
); );
``` ```
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
erklärbar. erklärbar.
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen **Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
WHERE "vorgangId" IS NOT NULL;`, würde das schließen. WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
--- ---
## 2. `Dokument.ocrStatus` ist ein freier String ## 2. `Dokument.ocrStatus` ist ein freier String
**Stand:** `ocrStatus String?` mit dem Kommentar **Stand:** `ocrStatus String?` mit dem Kommentar
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. `"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in **Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche `docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt (`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
heute erst auf, wenn das Abzeichen leer bleibt. heute erst auf, wenn das Abzeichen leer bleibt.
--- ---
## 3. Kein Index auf `Dokument.objektSchluessel` ## 3. Kein Index auf `Dokument.objektSchluessel`
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das **Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also **Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
--- ---
## 4. Keine Dublettenerkennung über die Prüfsumme ## 4. Keine Dublettenerkennung über die Prüfsumme
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer **Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload **Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
--- ---
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` ## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim **Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
Verknüpfen muss deshalb über die Beziehung gehen Verknüpfen muss deshalb über die Beziehung gehen
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). (`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen **Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
passieren. passieren.
--- ---
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel ## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein **Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
OCR-Lauf zuletzt versucht wurde, steht nirgends. OCR-Lauf zuletzt versucht wurde, steht nirgends.
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in **Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf `src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein `ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
bei jedem Nachziehen erneut durch Tesseract zu schicken. bei jedem Nachziehen erneut durch Tesseract zu schicken.
--- ---
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien ## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
festgehalten gehört. Bei `Dokument.ocrText` steht: festgehalten gehört. Bei `Dokument.ocrText` steht:
> „die Datei selbst liegt verschlüsselt im Objektspeicher“ > „die Datei selbst liegt verschlüsselt im Objektspeicher“
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für `ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — `signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet — Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos. sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()` Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt, würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
berührt. berührt.
--- ---
# Anhang: kein Schema-Wunsch, aber dringend # Anhang: kein Schema-Wunsch, aber dringend
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen ## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
also auch die Berichte aus Etappe 5. also auch die Berichte aus Etappe 5.
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit **Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
``` ```
TypeError: Cannot read properties of undefined (reading 'unitsPerEm') TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
at @react-pdf/textkit/lib/textkit.js:3090 at @react-pdf/textkit/lib/textkit.js:3090
``` ```
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist — auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
schuld: in reinem Node reproduziert es genauso. schuld: in reinem Node reproduziert es genauso.
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` **Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
inzwischen weitergewandert: inzwischen weitergewandert:
| Paket | installiert | von renderer 4.1.6 erwartet | | Paket | installiert | von renderer 4.1.6 erwartet |
|---|---|---| |---|---|---|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` | | `@react-pdf/font` | 3.1.0 | `^3.0.1` |
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | | `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) | | `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher `@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
löste die Schicht darunter den bloßen Namen selbst auf. löste die Schicht darunter den bloßen Namen selbst auf.
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit `@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen `layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
beiden klafft eine Lücke. beiden klafft eine Lücke.
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende **Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`). „Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier): **Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — `package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
```jsonc ```jsonc
"overrides": { "overrides": {
"@react-pdf/layout": "4.2.x", "@react-pdf/layout": "4.2.x",
"@react-pdf/textkit": "5.x" "@react-pdf/textkit": "5.x"
} }
``` ```
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen `@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
Unterabhängigkeiten wieder zusammenpassen. Danach kann Unterabhängigkeiten wieder zusammenpassen. Danach kann
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` `standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
+243 -243
View File
@@ -1,243 +1,243 @@
# Schema-Wünsche aus Etappe 5 „Behörde“ # Schema-Wünsche aus Etappe 5 „Behörde“
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
Behelf, der jetzt an seiner Stelle steht. Behelf, der jetzt an seiner Stelle steht.
--- ---
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig ## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
Sicht des Moduls, das die Gegenüberstellung baut. Sicht des Moduls, das die Gegenüberstellung baut.
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher **Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
Pflegegrad 3 um 1.319 Euro im Monat. Pflegegrad 3 um 1.319 Euro im Monat.
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart **Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in `EIGENANTEIL_NETTO` (siehe Kopfkommentar in
`src/lib/sozialhilfe/heimkosten.ts`): `src/lib/sozialhilfe/heimkosten.ts`):
``` ```
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung) Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
+ unterkunftVerpflegung + investitionskosten + unterkunftVerpflegung + investitionskosten
+ ausbildungsumlage + zusatzleistungen + ausbildungsumlage + zusatzleistungen
+ barbetrag + bekleidungspauschale + barbetrag + bekleidungspauschale
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
+ sozialhilfe + eigenmittel + sozialhilfe + eigenmittel
Deckungslücke = Bedarf − Deckung Deckungslücke = Bedarf − Deckung
``` ```
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als `pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich `pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor. Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der `leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
auf der Heimrechnung als eigene Abzugsposition. auf der Heimrechnung als eigene Abzugsposition.
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung `PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der
Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu
speichern. speichern.
**Wunsch, in dieser Reihenfolge:** **Wunsch, in dieser Reihenfolge:**
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und 1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
nicht nur hier: nicht nur hier:
```prisma ```prisma
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der /// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch /// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT /// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
/// als Deckung, sondern nur nachrichtlich. /// als Deckung, sondern nur nachrichtlich.
pflegeEigenanteilCent BigInt @default(0) pflegeEigenanteilCent BigInt @default(0)
``` ```
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes 2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
Modul raten, was in der Zeile steht: Modul raten, was in der Zeile steht:
```prisma ```prisma
enum Kostenlesart { enum Kostenlesart {
EIGENANTEIL_NETTO EIGENANTEIL_NETTO
PFLEGESATZ_BRUTTO PFLEGESATZ_BRUTTO
} }
lesart Kostenlesart @default(EIGENANTEIL_NETTO) lesart Kostenlesart @default(EIGENANTEIL_NETTO)
``` ```
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den 3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
die Summe könnte gegen sie geprüft werden. die Summe könnte gegen sie geprüft werden.
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul, **Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto- das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
Eigenanteil ein und lässt `pflegekasseCent` auf 0. Eigenanteil ein und lässt `pflegekasseCent` auf 0.
--- ---
## 2. `Nachforderung.checklistenSchluessel` — wichtig ## 2. `Nachforderung.checklistenSchluessel` — wichtig
```prisma ```prisma
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts. /// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen. /// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
checklistenSchluessel String? checklistenSchluessel String?
@@unique([antragId, checklistenSchluessel]) @@unique([antragId, checklistenSchluessel])
``` ```
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der **Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben, `nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es, getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
entsteht beim nächsten Übernehmen eine Dublette. entsteht beim nächsten Übernehmen eine Dublette.
**Behelf**: Vergleich über die Bezeichnung in **Behelf**: Vergleich über die Bezeichnung in
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`. `src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
--- ---
## 3. Rückkaufswert am Vertrag — wichtig ## 3. Rückkaufswert am Vertrag — wichtig
```prisma ```prisma
model Vertrag { model Vertrag {
// ... // ...
rueckkaufswertCent BigInt? rueckkaufswertCent BigInt?
rueckkaufswertStichtag DateTime? @db.Date rueckkaufswertStichtag DateTime? @db.Date
} }
``` ```
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge **Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`, nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die
Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
zusätzlich ein Vermögensposten angelegt wurde. zusätzlich ein Vermögensposten angelegt wurde.
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen. Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen **Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
Wert haben können (`MIT_RUECKKAUFSWERT` aus Wert haben können (`MIT_RUECKKAUFSWERT` aus
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht `src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der `Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt. Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
--- ---
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel ## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
```prisma ```prisma
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet. /// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
rechtsbehelfsbelehrungVorhanden Boolean @default(true) rechtsbehelfsbelehrungVorhanden Boolean @default(true)
rechtsbehelfsbelehrungBemerkung String? rechtsbehelfsbelehrungBemerkung String?
``` ```
**Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste **Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste
Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“). Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“).
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen — Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
das trägt, verliert aber die Begründung, und die bräuchte man genau dann, das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
wenn man sich im Widerspruchsverfahren darauf beruft. wenn man sich im Widerspruchsverfahren darauf beruft.
**Behelf**: `belehrungZuMonaten()` in **Behelf**: `belehrungZuMonaten()` in
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf `src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
Weiteres in `notizenVerschluesselt`. Weiteres in `notizenVerschluesselt`.
--- ---
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel ## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
```prisma ```prisma
widerspruchFristId String? @unique widerspruchFristId String? @unique
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull) widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
``` ```
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über **Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden. `Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter
Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids
mitziehen. Dasselbe gilt für mitziehen. Dasselbe gilt für
`"sozialhilfe-nachforderung-<nachforderungId>"`. `"sozialhilfe-nachforderung-<nachforderungId>"`.
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`. **Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
--- ---
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel ## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
```prisma ```prisma
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid. /// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
gezahltCent BigInt? gezahltCent BigInt?
gezahltAm DateTime? @db.Date gezahltAm DateTime? @db.Date
rechnungsnummer String? rechnungsnummer String?
``` ```
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was **Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
Träger nachzufragen. Träger nachzufragen.
**Behelf**: `bemerkung`. **Behelf**: `bemerkung`.
--- ---
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein ## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
```prisma ```prisma
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung. /// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
abwesenheitsminderungCent BigInt @default(0) abwesenheitsminderungCent BigInt @default(0)
``` ```
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt **Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach `abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die 25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
nicht festhalten. nicht festhalten.
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und **Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet. neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
--- ---
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein ## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
```prisma ```prisma
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr. /// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
antragsschlussMonat Int? antragsschlussMonat Int?
antragsschlussTag Int? antragsschlussTag Int?
``` ```
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden **Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten, Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
statt sie von Hand anzulegen. statt sie von Hand anzulegen.
**Behelf**: `antragsschluss` als Text im Katalog **Behelf**: `antragsschluss` als Text im Katalog
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der (`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`). `landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
--- ---
## 9. `Nachforderung.notizenVerschluesselt` — klein ## 9. `Nachforderung.notizenVerschluesselt` — klein
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im **Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die („Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas. Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie **Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
verschlüsselt werden sollen, gehört `Nachforderung` in verschlüsselt werden sollen, gehört `Nachforderung` in
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`. `VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from 'next/link' import Link from 'next/link'
import { import {
Archive, Archive,
CalendarClock, CalendarClock,
FolderArchive, FolderArchive,
Paperclip, Paperclip,
ScrollText, ScrollText,
} from 'lucide-react' } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste' import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext' import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten' import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils' import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' } export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */ /** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [ const BERICHTSFRISTEN = [
'anfangsbericht', 'anfangsbericht',
'jahresbericht', 'jahresbericht',
'vermoegensverzeichnis', 'vermoegensverzeichnis',
'vermoegensuebersicht', 'vermoegensuebersicht',
'rechnungslegung', 'rechnungslegung',
] ]
const WEGE = [ const WEGE = [
{ {
href: '/berichte/jahresbericht', href: '/berichte/jahresbericht',
titel: 'Jahresbericht', titel: 'Jahresbericht',
beschreibung: beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.', 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText, icon: ScrollText,
}, },
{ {
href: '/berichte/belegmappe', href: '/berichte/belegmappe',
titel: 'Belegmappe', titel: 'Belegmappe',
beschreibung: beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.', 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive, icon: FolderArchive,
}, },
{ {
href: '/berichte/unterlagenpaket', href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger', titel: 'Unterlagen für den Träger',
beschreibung: beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.', 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip, icon: Paperclip,
}, },
{ {
href: '/berichte/export', href: '/berichte/export',
titel: 'Vollexport', titel: 'Vollexport',
beschreibung: beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.', 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive, icon: Archive,
}, },
] ]
/** /**
* Die Berichtsuebersicht beantwortet zwei Fragen: * Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?". * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
* *
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten. * Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/ */
export default async function BerichteSeite() { export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([ const [fristen, zeilen] = await Promise.all([
db.frist.findMany({ db.frist.findMany({
where: { where: {
betreuteId, betreuteId,
status: 'OFFEN', status: 'OFFEN',
...nichtGeloescht, ...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({ OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel }, systemSchluessel: { startsWith: schluessel },
})), })),
}, },
orderBy: { datum: 'asc' }, orderBy: { datum: 'asc' },
take: 10, take: 10,
}), }),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben), berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
]) ])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Berichte" titel="Berichte"
pfad={[{ titel: 'Berichte' }]} pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast." beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/> />
<section className="space-y-3" aria-label="Demnächst fällig"> <section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2> <h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? ( {fristen.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '} Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4"> <Link href="/fristen" className="underline underline-offset-4">
Fristen Fristen
</Link> </Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind. , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{fristen.map((frist) => { {fristen.map((frist) => {
const tage = tageBis(frist.datum) const tage = tageBis(frist.datum)
const variante = const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen' tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return ( return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3"> <li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" /> <CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span> <span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)} {formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''} {frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span> </span>
</span> </span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>} {frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}> <Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)} {tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge> </Badge>
</li> </li>
) )
})} })}
</ul> </ul>
)} )}
</section> </section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?"> <section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => { {WEGE.map((weg) => {
const Icon = weg.icon const Icon = weg.icon
return ( return (
<Card key={weg.href}> <Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0"> <CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" /> <Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-lg"> <CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline"> <Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel} {weg.titel}
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription> <CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
) )
})} })}
</section> </section>
<section className="space-y-3" aria-label="Erzeugte Berichte"> <section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2> <h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} /> <BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section> </section>
<HinweisKarten kontext={['BERICHTE']} /> <HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" /> <Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div> </div>
) )
} }
@@ -1,149 +1,149 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag' import { betragFormatieren } from '@/lib/finanzen/betrag'
import { import {
fristAmpel, fristAmpel,
rechtsbehelfsfristBerechnen, rechtsbehelfsfristBerechnen,
zugangsvermutung, zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf' } from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe' import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Bescheid-Prüfung. * Bescheid-Prüfung.
* *
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist. * können, wo die Rechnung abgebogen ist.
*/ */
export default async function BescheidSeite({ export default async function BescheidSeite({
params, params,
}: { }: {
params: Promise<{ antragId: string; bescheidId: string }> params: Promise<{ antragId: string; bescheidId: string }>
}) { }) {
const { antragId, bescheidId } = await params const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({ const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
}) })
if (!bescheid) notFound() if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid) const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({ const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm, zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom, bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate, fristMonate: bescheid.rechtsbehelfsfristMonate,
heute, heute,
}) })
const vorschlag = zugangsvermutung(bescheid.bescheidVom) const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`} titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={ beschreibung={
<> <>
{bescheid.antrag.traeger} {bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)} {formatDatum(bescheid.zugegangenAm)}
</> </>
} }
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' }, { titel: 'Bescheid' },
]} ]}
/> />
<BescheidPruefung <BescheidPruefung
bescheidId={bescheid.id} bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus} pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={ widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : '' bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
} }
darfSchreiben={darfSchreiben} darfSchreiben={darfSchreiben}
rechenweg={{ rechenweg={{
berechenbar: ergebnis.berechenbar, berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis) ? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null, : null,
rechnerischesEnde: ergebnis.rechnerischesEnde rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde) ? alsIsoTag(ergebnis.rechnerischesEnde)
: null, : null,
verschoben: ergebnis.verschoben, verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend, tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen, abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis), ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte, schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise, hinweise: ergebnis.hinweise,
}} }}
/> />
{offen.notizen && ( {offen.notizen && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Notizen</CardTitle> <CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription> <CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{darfSchreiben && ( {darfSchreiben && (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2> <h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular <BescheidFormular
antragId={antragId} antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }} zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{ werte={{
id: bescheid.id, id: bescheid.id,
art: bescheid.art, art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom), bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '', zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '', zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '', zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent), betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate, rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm) ? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '', : '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
<div className="border-t pt-6"> <div className="border-t pt-6">
<AktionMitRueckfrage <AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)} aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage." frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline" variant="outline"
size="default" size="default"
> >
<Trash2 aria-hidden="true" /> <Trash2 aria-hidden="true" />
Bescheid löschen Bescheid löschen
</AktionMitRueckfrage> </AktionMitRueckfrage>
</div> </div>
</section> </section>
)} )}
</div> </div>
) )
} }
+232 -232
View File
@@ -1,232 +1,232 @@
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils' import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Vermögens-Bewertungsansicht. * Vermögens-Bewertungsansicht.
* *
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/ */
export default async function VermoegensbewertungSeite() { export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([ const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({ db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht }, where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
db.vertrag.findMany({ db.vertrag.findMany({
where: { where: {
betreuteId, betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein // Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen. // Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] }, art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht, ...nichtGeloescht,
}, },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
]) ])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin. // zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set( const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null), posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
) )
const zeilen: ListenPosten[] = [ const zeilen: ListenPosten[] = [
...posten.map((p) => { ...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p) const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0] const stand = p.wertstaende[0]
return { return {
id: p.id, id: p.id,
bezeichnung: p.bezeichnung, bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const, herkunft: 'VERMOEGEN' as const,
art: p.art, art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art], artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe, bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null, wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null, zusatz: stand ? stand.wertquelle : null,
} }
}), }),
...vertraege ...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id)) .filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => { .map((v) => {
const geoeffnet = oeffnen('Vertrag', v) const geoeffnet = oeffnen('Vertrag', v)
return { return {
id: v.id, id: v.id,
bezeichnung: v.bezeichnung, bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const, herkunft: 'VERTRAG' as const,
art: v.art, art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art], artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe, bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen. // als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null, wertCent: null,
wertStichtag: null, wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft, zusatz: v.gesellschaft,
} }
}), }),
] ]
const uebersicht = uebersichtBilden( const uebersicht = uebersichtBilden(
zeilen.map((z) => ({ zeilen.map((z) => ({
id: z.id, id: z.id,
bezeichnung: z.bezeichnung, bezeichnung: z.bezeichnung,
herkunft: z.herkunft, herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung, artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung, bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent), wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null, wertStichtag: null,
begruendung: z.begruendung, begruendung: z.begruendung,
href: '#', href: '#',
})), })),
) )
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Vermögen bewerten" titel="Vermögen bewerten"
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter." beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' }, { titel: 'Vermögen' },
]} ]}
/> />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR} titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent} betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl} anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert} ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot" farbe="text-ampel-rot"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT} titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent} betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl} anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert} ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen" farbe="text-ampel-gruen"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG} titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent} betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl} anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert} ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange" farbe="text-ampel-orange"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT} titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent} betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl} anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert} ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground" farbe="text-muted-foreground"
/> />
</div> </div>
<Card> <Card>
<CardContent className="space-y-2 pt-5"> <CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span> </span>
<span className="text-lg font-semibold tabular-nums"> <span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)} {formatCent(vergleich.freibetragCent)}
</span> </span>
</div> </div>
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span> <span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
<span <span
className={`text-lg font-semibold tabular-nums ${ className={`text-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen' vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`} }`}
> >
{formatCent(vergleich.ueberFreibetragCent)} {formatCent(vergleich.ueberFreibetragCent)}
</span> </span>
</div> </div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p> <p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent> </CardContent>
</Card> </Card>
{uebersicht.ungeprueft.length > 0 && ( {uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle> <AlertTitle>
{uebersicht.ungeprueft.length}{' '} {uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft {uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen. kostet Wochen.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{uebersicht.ohneBegruendung.length > 0 && ( {uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle> <AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription> <AlertDescription>
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf {uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist. sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} /> <Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div> </div>
) )
} }
function Summenkachel({ function Summenkachel({
titel, titel,
betrag, betrag,
anzahl, anzahl,
ohneWert, ohneWert,
farbe, farbe,
}: { }: {
titel: string titel: string
betrag: bigint betrag: bigint
anzahl: number anzahl: number
ohneWert: number ohneWert: number
farbe: string farbe: string
}) { }) {
return ( return (
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p> <p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p> <p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
@@ -1,77 +1,77 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular' import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */ /** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string { function centAlsText(cent: bigint | null): string {
if (cent === null) return '' if (cent === null) return ''
const negativ = cent < 0n const negativ = cent < 0n
const betrag = negativ ? -cent : cent const betrag = negativ ? -cent : cent
const euro = betrag / 100n const euro = betrag / 100n
const rest = betrag % 100n const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}` return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
} }
export default async function VertragBearbeiten({ export default async function VertragBearbeiten({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`) if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`} titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert." beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' }, { titel: 'Bearbeiten' },
]} ]}
/> />
<VertragFormular <VertragFormular
betreuteId={aktive.id} betreuteId={aktive.id}
werte={{ werte={{
id: vertrag.id, id: vertrag.id,
bezeichnung: vertrag.bezeichnung, bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '', gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art, art: vertrag.art,
status: vertrag.status, status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '', vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '', beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '', endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '', kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '', kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '', gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent), beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '', rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe, bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '', bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
</div> </div>
) )
} }
+96 -96
View File
@@ -1,96 +1,96 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent' import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent' import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung' import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({ export default async function VertragKuendigen({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE') const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const paket = kuendigungsassistent({ const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }), kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer, vertragsnummer: offen.vertragsnummer,
art: vertrag.art, art: vertrag.art,
laufzeit: { laufzeit: {
beginnAm: vertrag.beginnAm, beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm, endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
}, },
heute, heute,
}) })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel="Kündigungs-Assistent" titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`} beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' }, { titel: 'Kündigen' },
]} ]}
/> />
<KuendigungsAssistent <KuendigungsAssistent
betreuteId={aktive.id} betreuteId={aktive.id}
vertragId={id} vertragId={id}
darfSchreiben={schreiben} darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)} heuteIso={alsIsoTag(heute)}
daten={{ daten={{
schreiben: paket.schreiben, schreiben: paket.schreiben,
warnungen: paket.warnungen, warnungen: paket.warnungen,
genehmigung: paket.genehmigung, genehmigung: paket.genehmigung,
versandnachweis: { versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten], moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis, hinweis: paket.versandnachweis.hinweis,
}, },
plan: { plan: {
berechenbar: paket.plan.berechenbar, berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin) ? alsIsoTag(paket.plan.naechsterTermin)
: null, : null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm) ? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null, : null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag) ? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null, : null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag, tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst, verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise, hinweise: paket.plan.hinweise,
}, },
}} }}
/> />
</div> </div>
) )
} }
+275 -275
View File
@@ -1,275 +1,275 @@
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } from 'lucide-react' import { FileX2, Pencil, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular' import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert' import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { import {
kuendigungsampel, kuendigungsampel,
kuendigungsplan, kuendigungsplan,
laufzeitAusVertrag, laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { import {
anfrageStatus, anfrageStatus,
rueckkaufswertAnschreiben, rueckkaufswertAnschreiben,
wertHistorie, wertHistorie,
} from '@/lib/vertraege/rueckkaufswert' } from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter' import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG, BEWERTUNG_ERKLAERUNG,
jahresbeitragCent, jahresbeitragCent,
kannRueckkaufswertHaben, kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils' import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) { export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}`) const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE') const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie(). // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: { vermoegensposten: {
where: nichtGeloescht, where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
}, },
}, },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: vertrag.beginnAm, beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm, endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
heute, heute,
}) })
const ampel = kuendigungsampel(plan) const ampel = kuendigungsampel(plan)
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute) const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
const kopf = briefkopfBauen({ betreute: aktive, benutzer }) const kopf = briefkopfBauen({ betreute: aktive, benutzer })
const anschreiben = rueckkaufswertAnschreiben({ const anschreiben = rueckkaufswertAnschreiben({
kopf, kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer, vertragsnummer: offen.vertragsnummer,
stichtag: heute, stichtag: heute,
heute, heute,
}) })
const wertstaende = wertHistorie(vertrag.vermoegensposten) const wertstaende = wertHistorie(vertrag.vermoegensposten)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel={vertrag.bezeichnung} titel={vertrag.bezeichnung}
beschreibung={ beschreibung={
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]] [vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean) .filter(Boolean)
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art] .join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
} }
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung }, { titel: vertrag.bezeichnung },
]} ]}
aktionen={ aktionen={
schreiben && ( schreiben && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/vertraege/${id}/kuendigen`}> <Link href={`/vertraege/${id}/kuendigen`}>
<FileX2 aria-hidden="true" /> <FileX2 aria-hidden="true" />
Kündigen Kündigen
</Link> </Link>
</Button> </Button>
<Button asChild> <Button asChild>
<Link href={`/vertraege/${id}/bearbeiten`}> <Link href={`/vertraege/${id}/bearbeiten`}>
<Pencil aria-hidden="true" /> <Pencil aria-hidden="true" />
Bearbeiten Bearbeiten
</Link> </Link>
</Button> </Button>
</div> </div>
) )
} }
/> />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="size-5" aria-hidden="true" /> <Shield className="size-5" aria-hidden="true" />
Vertragsdaten Vertragsdaten
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt. Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-x-8 sm:grid-cols-2"> <CardContent className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y"> <dl className="divide-y">
<Datenzeile beschriftung="Vertragsnummer"> <Datenzeile beschriftung="Vertragsnummer">
{offen.vertragsnummer ?? '–'} {offen.vertragsnummer ?? '–'}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Status"> <Datenzeile beschriftung="Status">
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]} {VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Laufzeitbeginn"> <Datenzeile beschriftung="Laufzeitbeginn">
{formatDatum(vertrag.beginnAm)} {formatDatum(vertrag.beginnAm)}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende"> <Datenzeile beschriftung="Vereinbartes Ende">
{formatDatum(vertrag.endeAm)} {formatDatum(vertrag.endeAm)}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist"> <Datenzeile beschriftung="Kündigungsfrist">
{vertrag.kuendigungsfristMonate !== null {vertrag.kuendigungsfristMonate !== null
? `${vertrag.kuendigungsfristMonate} Monate` ? `${vertrag.kuendigungsfristMonate} Monate`
: 'nicht erfasst'} : 'nicht erfasst'}
</Datenzeile> </Datenzeile>
</dl> </dl>
<dl className="divide-y"> <dl className="divide-y">
<Datenzeile beschriftung="Beitrag"> <Datenzeile beschriftung="Beitrag">
{formatCent(vertrag.beitragCent)} {formatCent(vertrag.beitragCent)}
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''} {vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile> <Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Gekündigt am"> <Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.gekuendigtAm)} {formatDatum(vertrag.gekuendigtAm)}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Gekündigt zum"> <Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)} {formatDatum(vertrag.kuendigungZum)}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt"> <Datenzeile beschriftung="Rückkaufswert angefragt">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)} {formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</Datenzeile> </Datenzeile>
</dl> </dl>
{offen.notizen && ( {offen.notizen && (
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2"> <div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Kündigungsfrist</CardTitle> <CardTitle>Kündigungsfrist</CardTitle>
<CardDescription> <CardDescription>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
Absendetag früher als der Fristablauf. Absendetag früher als der Fristablauf.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{plan.berechenbar ? ( {plan.berechenbar ? (
<> <>
<dl className="grid gap-3 sm:grid-cols-3"> <dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt> <dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd> <dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div> </div>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt> <dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
{formatDatum(plan.zugangSpaetestensAm)} {formatDatum(plan.zugangSpaetestensAm)}
</dd> </dd>
</div> </div>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt> <dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)} {formatDatum(plan.spaetesterAbsendetag)}
</dd> </dd>
{ampel && ( {ampel && (
<dd className="mt-1"> <dd className="mt-1">
<Badge variant={ampel.variante}>{ampel.text}</Badge> <Badge variant={ampel.variante}>{ampel.text}</Badge>
</dd> </dd>
)} )}
</div> </div>
</dl> </dl>
<ul className="space-y-1.5 text-sm text-muted-foreground"> <ul className="space-y-1.5 text-sm text-muted-foreground">
{plan.hinweise.map((hinweis) => ( {plan.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li> <li key={hinweis}>{hinweis}</li>
))} ))}
</ul> </ul>
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{plan.hinweise[0] ?? {plan.hinweise[0] ??
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'} 'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
</p> </p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{kannRueckkaufswertHaben(vertrag.art) && ( {kannRueckkaufswertHaben(vertrag.art) && (
<Rueckkaufswert <Rueckkaufswert
betreuteId={aktive.id} betreuteId={aktive.id}
vertragId={id} vertragId={id}
darfSchreiben={schreiben} darfSchreiben={schreiben}
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null} angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig} erinnerungFaellig={anfrage.erinnerungFaellig}
tageSeitAnfrage={anfrage.tageSeitAnfrage} tageSeitAnfrage={anfrage.tageSeitAnfrage}
anschreiben={anschreiben} anschreiben={anschreiben}
stichtagVorschlag={alsIsoTag(heute)} stichtagVorschlag={alsIsoTag(heute)}
alterHinweis={ alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
} }
werte={wertstaende.map((w) => ({ werte={wertstaende.map((w) => ({
id: w.id, id: w.id,
stichtag: w.stichtag.toISOString(), stichtag: w.stichtag.toISOString(),
wertCent: centZuZahl(w.wertCent) ?? 0, wertCent: centZuZahl(w.wertCent) ?? 0,
wertquelle: w.wertquelle, wertquelle: w.wertquelle,
bemerkung: w.bemerkung, bemerkung: w.bemerkung,
}))} }))}
/> />
)} )}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Bewertung fürs Sozialamt</CardTitle> <CardTitle>Bewertung fürs Sozialamt</CardTitle>
<CardDescription> <CardDescription>
Meine Einordnung und meine Begründung — keine Rechtsauskunft. Meine Einordnung und meine Begründung — keine Rechtsauskunft.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge> <Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]} {BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p> </p>
{offen.bewertungBegruendung ? ( {offen.bewertungBegruendung ? (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm"> <p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{offen.bewertungBegruendung} {offen.bewertungBegruendung}
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht. Aufstellung an den Bezirk auftaucht.
</p> </p>
)} )}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) )
} }
+438 -438
View File
@@ -1,438 +1,438 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react' import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client' import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import { import {
GenehmigungsKette, GenehmigungsKette,
type GenehmigungsAnzeige, type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette' } from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import { import {
VORGANG_STATUS_BESCHRIFTUNG, VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen, zeitstrahlBauen,
type ZeitstrahlStatuswechsel, type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl' } from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
/** /**
* Die Fallakte. * Die Fallakte.
* *
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der * darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/ */
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound() if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({ const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: { genehmigungen: {
where: nichtGeloescht, where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } }, include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' }, orderBy: { erstelltAm: 'asc' },
}, },
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } }, verknuepfungen: { include: { dokument: true } },
ansprechpartner: true, ansprechpartner: true,
}, },
}) })
if (!vorgang) notFound() if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE') const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang) const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners. // Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({ const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true }, select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }], orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300, take: 300,
}) })
// --- Statuswechsel aus dem Audit-Log -------------------------------------- // --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle. // Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({ const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' }, orderBy: { zeitpunkt: 'asc' },
take: 200, take: 200,
}) })
const statuswechsel: ZeitstrahlStatuswechsel[] = [] const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) { for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich( const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt, eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt', 'aenderungVerschluesselt',
) )
if (!roh) continue if (!roh) continue
try { try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }> const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({ statuswechsel.push({
id: eintrag.id, id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt, zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus, nach: status.neu as VorgangStatus,
wer: eintrag.userName, wer: eintrag.userName,
}) })
} catch { } catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue continue
} }
} }
// --- Zeitstrahl ----------------------------------------------------------- // --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({ const zeitstrahl = zeitstrahlBauen({
vorgang: { vorgang: {
titel: vorgang.titel, titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm, erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm, erledigtAm: vorgang.erledigtAm,
}, },
dokumente: vorgang.verknuepfungen dokumente: vorgang.verknuepfungen
.map((v) => v.dokument) .map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null) .filter((d) => d.geloeschtAm === null)
.map((d) => ({ .map((d) => ({
id: d.id, id: d.id,
titel: d.titel, titel: d.titel,
eingangAm: d.eingangAm, eingangAm: d.eingangAm,
versandAm: d.versandAm, versandAm: d.versandAm,
erstelltAm: d.erstelltAm, erstelltAm: d.erstelltAm,
absender: d.absender, absender: d.absender,
empfaenger: d.empfaenger, empfaenger: d.empfaenger,
})), })),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p) const klar = oeffnen('Kontaktprotokoll', p)
return { return {
id: p.id, id: p.id,
datum: p.datum, datum: p.datum,
art: p.art, art: p.art,
gespraechspartner: p.gespraechspartner, gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter, mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null, inhalt: klar.inhalt ?? null,
} }
}), }),
fristen: vorgang.fristen.map((f) => ({ fristen: vorgang.fristen.map((f) => ({
id: f.id, id: f.id,
titel: f.titel, titel: f.titel,
datum: f.datum, datum: f.datum,
erledigtAm: f.erledigtAm, erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist, ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage, rechtsgrundlage: f.rechtsgrundlage,
})), })),
buchungen: vorgang.buchungen.map((b) => { buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b) const klar = oeffnen('Buchung', b)
return { return {
id: b.id, id: b.id,
datum: b.datum, datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0, betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung, richtung: b.richtung,
gegenstelle: b.gegenstelle, gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null, verwendungszweck: klar.verwendungszweck ?? null,
} }
}), }),
genehmigungen: vorgang.genehmigungen.map((g) => ({ genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id, id: g.id,
titel: g.titel, titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage, rechtsgrundlage: g.rechtsgrundlage,
status: g.status, status: g.status,
mitteilungAm: g.mitteilungAm, mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm, antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm, anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm, beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm, rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm, vollzugAm: g.vollzugAm,
})), })),
statuswechsel, statuswechsel,
}) })
// --- Sachstandsanfrage ---------------------------------------------------- // --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde. // Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({ ? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel, vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle, gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen, aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm, letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: { betreute: {
vorname: aktive.vorname, vorname: aktive.vorname,
nachname: aktive.nachname, nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum, geburtsdatum: aktive.geburtsdatum,
}, },
betreuer: { betreuer: {
name: benutzer.name, name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht, aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht, betreuungsgericht: aktive.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner ansprechpartner: vorgang.ansprechpartner
? { ? {
name: vorgang.ansprechpartner.name, name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede, anrede: vorgang.ansprechpartner.anrede,
} }
: null, : null,
}) })
: null : null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({ ? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true }, select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' }, orderBy: { erstelltAm: 'desc' },
take: 100, take: 100,
}) })
: [] : []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id, id: a.id,
titel: a.titel, titel: a.titel,
beschreibung: a.beschreibung, beschreibung: a.beschreibung,
erledigt: a.erledigt, erledigt: a.erledigt,
faelligAm: a.faelligAm, faelligAm: a.faelligAm,
})) }))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id, id: g.id,
titel: g.titel, titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage, rechtsgrundlage: g.rechtsgrundlage,
status: g.status, status: g.status,
mitteilungAm: g.mitteilungAm, mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm, antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm, anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm, beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm, rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm, vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId, beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente, dokumente,
})) }))
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel={vorgang.titel} titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined} beschreibung={offen.beschreibung ?? undefined}
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' }, { titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel }, { titel: vorgang.titel },
]} ]}
aktionen={ aktionen={
<> <>
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href="/vorgaenge"> <Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" /> <ArrowLeft aria-hidden="true" />
Zurück Zurück
</Link> </Link>
</Button> </Button>
{anfrage && ( {anfrage && (
<SachstandsanfrageKnopf <SachstandsanfrageKnopf
vorgangId={vorgang.id} vorgangId={vorgang.id}
betreff={anfrage.betreff} betreff={anfrage.betreff}
text={anfrage.text} text={anfrage.text}
darfSchreiben={schreiben} darfSchreiben={schreiben}
/> />
)} )}
{schreiben && ( {schreiben && (
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}> <Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" /> <FileDown aria-hidden="true" />
Als Word-Datei und in die Akte Als Word-Datei und in die Akte
</Link> </Link>
</Button> </Button>
)} )}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />} {schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</> </>
} }
/> />
{/* --- Kopf der Akte ---------------------------------------------- */} {/* --- Kopf der Akte ---------------------------------------------- */}
<Card> <Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3"> <CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p> <p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p> <p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && ( {schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein /> <StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p> <p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p> <p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && ( {vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground"> <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" /> <Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)} Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p> </p>
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p> <p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p> <p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div> </div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse {/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */} stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p> <p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? ( {vorgang.ansprechpartner ? (
<> <>
<p className="font-medium"> <p className="font-medium">
<Link <Link
href={`/kontakte/${vorgang.ansprechpartner.id}`} href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline" className="hover:underline"
> >
{vorgang.ansprechpartner.name} {vorgang.ansprechpartner.name}
</Link> </Link>
</p> </p>
{vorgang.ansprechpartner.organisation && ( {vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation} {vorgang.ansprechpartner.organisation}
</p> </p>
)} )}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && ( {(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email] {[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
</p> </p>
)} )}
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“. Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p> </p>
)} )}
</div> </div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p> <p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium"> <p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p> <p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge> <Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge> </Badge>
<Faelligkeit datum={vorgang.faelligAm} /> <Faelligkeit datum={vorgang.faelligAm} />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* --- Aufgaben ----------------------------------------------------- */} {/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4"> <section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold"> <h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben Aufgaben
</h2> </h2>
<AufgabenListe <AufgabenListe
vorgangId={vorgang.id} vorgangId={vorgang.id}
aufgaben={aufgaben} aufgaben={aufgaben}
darfSchreiben={schreiben} darfSchreiben={schreiben}
/> />
</section> </section>
{/* --- Genehmigungen ------------------------------------------------ */} {/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6"> <section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold"> <h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts Genehmigung des Betreuungsgerichts
</h2> </h2>
{genehmigungen.map((genehmigung) => ( {genehmigungen.map((genehmigung) => (
<GenehmigungsKette <GenehmigungsKette
key={genehmigung.id} key={genehmigung.id}
genehmigung={genehmigung} genehmigung={genehmigung}
darfSchreiben={schreiben} darfSchreiben={schreiben}
/> />
))} ))}
{schreiben && genehmigungen.length === 0 && ( {schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} /> <GenehmigungAnlegen vorgangId={vorgang.id} />
)} )}
</section> </section>
)} )}
{/* --- Zeitstrahl --------------------------------------------------- */} {/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4"> <section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold"> <h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl Zeitstrahl
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst. zuerst.
</p> </p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} /> <ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section> </section>
{/* --- Bearbeiten --------------------------------------------------- */} {/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && ( {schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4"> <section aria-labelledby="bearbeiten" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle> <CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<VorgangsFormular <VorgangsFormular
modus="aendern" modus="aendern"
betreuteId={aktive.id} betreuteId={aktive.id}
vorgangId={vorgang.id} vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`} abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte} kontakte={kontakte}
werte={{ werte={{
titel: vorgang.titel, titel: vorgang.titel,
kategorie: vorgang.kategorie, kategorie: vorgang.kategorie,
status: vorgang.status, status: vorgang.status,
prioritaet: vorgang.prioritaet, prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null, beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle, gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen, aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId, ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich, verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt, naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm, faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm, letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig, genehmigungspflichtig: vorgang.genehmigungspflichtig,
}} }}
/> />
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
)} )}
</div> </div>
) )
} }
+310 -310
View File
@@ -1,310 +1,310 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine' import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten' import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe' import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = { export type HeimkostenWerte = {
jahr: number jahr: number
monat: number monat: number
pflegeEigenanteil: string pflegeEigenanteil: string
unterkunftVerpflegung: string unterkunftVerpflegung: string
investitionskosten: string investitionskosten: string
ausbildungsumlage: string ausbildungsumlage: string
zusatzleistungen: string zusatzleistungen: string
pflegekasse: string pflegekasse: string
leistungszuschlagProzent: number leistungszuschlagProzent: number
leistungszuschlag: string leistungszuschlag: string
rente: string rente: string
sonstigeEinkuenfte: string sonstigeEinkuenfte: string
sozialhilfe: string sozialhilfe: string
eigenmittel: string eigenmittel: string
barbetrag: string barbetrag: string
bekleidungspauschale: string bekleidungspauschale: string
abwesenheitstage: number abwesenheitstage: number
bemerkung: string bemerkung: string
} }
/** /**
* Ein Monat der Gegenüberstellung. * Ein Monat der Gegenüberstellung.
* *
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der * Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person * Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der * verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht. * Stelle, an der es auf der Rechnung steht.
*/ */
export function HeimkostenFormular({ export function HeimkostenFormular({
werte, werte,
jahre, jahre,
hinweisPflegekasse, hinweisPflegekasse,
vorschlagBarbetrag, vorschlagBarbetrag,
}: { }: {
werte: HeimkostenWerte werte: HeimkostenWerte
jahre: number[] jahre: number[]
/** /**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN * Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der * Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert * Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts. * deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/ */
hinweisPflegekasse: string hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string } vorschlagBarbetrag: { betrag: string; text: string }
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag) const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return ( return (
<form action={aktion} className="space-y-6"> <form action={aktion} className="space-y-6">
<Meldung zustand={zustand} /> <Meldung zustand={zustand} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Monat erfassen</CardTitle> <CardTitle>Monat erfassen</CardTitle>
<CardDescription> <CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben. überschrieben.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}> <Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}> <Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => ( {jahre.map((j) => (
<option key={j} value={j}> <option key={j} value={j}>
{j} {j}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}> <Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}> <Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => ( {MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}> <option key={name} value={i + 1}>
{name} {name}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</div> </div>
<Feldgruppe <Feldgruppe
titel="Kosten" titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen." beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
> >
<Feld <Feld
id="pflegeEigenanteil" id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil" beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil} fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag." erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
> >
<Input <Input
name="pflegeEigenanteil" name="pflegeEigenanteil"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.pflegeEigenanteil} defaultValue={werte.pflegeEigenanteil}
/> />
</Feld> </Feld>
<Feld <Feld
id="unterkunftVerpflegung" id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung" beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung} fehler={zustand.felder?.unterkunftVerpflegung}
> >
<Input <Input
name="unterkunftVerpflegung" name="unterkunftVerpflegung"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung} defaultValue={werte.unterkunftVerpflegung}
/> />
</Feld> </Feld>
<Feld <Feld
id="investitionskosten" id="investitionskosten"
beschriftung="Investitionskosten" beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten} fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter." erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
> >
<Input <Input
name="investitionskosten" name="investitionskosten"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.investitionskosten} defaultValue={werte.investitionskosten}
/> />
</Feld> </Feld>
<Feld <Feld
id="ausbildungsumlage" id="ausbildungsumlage"
beschriftung="Ausbildungsumlage" beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage} fehler={zustand.felder?.ausbildungsumlage}
> >
<Input <Input
name="ausbildungsumlage" name="ausbildungsumlage"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.ausbildungsumlage} defaultValue={werte.ausbildungsumlage}
/> />
</Feld> </Feld>
<Feld <Feld
id="zusatzleistungen" id="zusatzleistungen"
beschriftung="Zusatzleistungen" beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen} fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab." erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
> >
<Input <Input
name="zusatzleistungen" name="zusatzleistungen"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.zusatzleistungen} defaultValue={werte.zusatzleistungen}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?"> <Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld <Feld
id="pflegekasse" id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)" beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse} fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse} erklaerung={hinweisPflegekasse}
> >
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} /> <Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlagProzent" id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent" beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent} fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung." erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
> >
<Input <Input
name="leistungszuschlagProzent" name="leistungszuschlagProzent"
type="number" type="number"
min={0} min={0}
max={100} max={100}
defaultValue={werte.leistungszuschlagProzent} defaultValue={werte.leistungszuschlagProzent}
/> />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlag" id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro" beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag} fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung." erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
> >
<Input <Input
name="leistungszuschlag" name="leistungszuschlag"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.leistungszuschlag} defaultValue={werte.leistungszuschlag}
/> />
</Feld> </Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}> <Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} /> <Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld> </Feld>
<Feld <Feld
id="sonstigeEinkuenfte" id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte" beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte} fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen." erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
> >
<Input <Input
name="sonstigeEinkuenfte" name="sonstigeEinkuenfte"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte} defaultValue={werte.sonstigeEinkuenfte}
/> />
</Feld> </Feld>
<Feld <Feld
id="sozialhilfe" id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe" beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe} fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt." erklaerung="Was der Bezirk laut Bescheid übernimmt."
> >
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} /> <Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld> </Feld>
<Feld <Feld
id="eigenmittel" id="eigenmittel"
beschriftung="Eigenmittel" beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel} fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde." erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
> >
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} /> <Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Was verbleiben muss" titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest." beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
> >
<Feld <Feld
id="barbetrag" id="barbetrag"
beschriftung="Barbetrag" beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag} fehler={zustand.felder?.barbetrag}
erklaerung={ erklaerung={
<> <>
{vorschlagBarbetrag.text}{' '} {vorschlagBarbetrag.text}{' '}
<button <button
type="button" type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)} onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline" className="font-medium text-primary underline-offset-4 hover:underline"
> >
Wert übernehmen Wert übernehmen
</button> </button>
</> </>
} }
> >
<Input <Input
name="barbetrag" name="barbetrag"
inputMode="decimal" inputMode="decimal"
value={barbetrag} value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)} onChange={(e) => setBarbetrag(e.target.value)}
/> />
</Feld> </Feld>
<Feld <Feld
id="bekleidungspauschale" id="bekleidungspauschale"
beschriftung="Bekleidungspauschale" beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale} fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid." erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
> >
<Input <Input
name="bekleidungspauschale" name="bekleidungspauschale"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.bekleidungspauschale} defaultValue={werte.bekleidungspauschale}
/> />
</Feld> </Feld>
<Feld <Feld
id="abwesenheitstage" id="abwesenheitstage"
beschriftung="Abwesenheitstage" beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage} fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung." erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
> >
<Input <Input
name="abwesenheitstage" name="abwesenheitstage"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={werte.abwesenheitstage} defaultValue={werte.abwesenheitstage}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}> <Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} /> <Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld> </Feld>
</CardContent> </CardContent>
</Card> </Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden> <Absenden>Monat speichern</Absenden>
</form> </form>
) )
} }
+237 -237
View File
@@ -1,237 +1,237 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client' import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular' import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG, BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege' import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege' import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz' import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = { export type VertragWerte = {
id?: string id?: string
bezeichnung: string bezeichnung: string
gesellschaft: string gesellschaft: string
art: string art: string
status: string status: string
vertragsnummer: string vertragsnummer: string
beginnAm: string beginnAm: string
endeAm: string endeAm: string
kuendigungsfristMonate: string kuendigungsfristMonate: string
kuendigungZum: string kuendigungZum: string
gekuendigtAm: string gekuendigtAm: string
beitrag: string beitrag: string
rhythmus: string rhythmus: string
bewertungSozialhilfe: string bewertungSozialhilfe: string
bewertungBegruendung: string bewertungBegruendung: string
notizen: string notizen: string
} }
export const LEERER_VERTRAG: VertragWerte = { export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '', bezeichnung: '',
gesellschaft: '', gesellschaft: '',
art: 'LEBENSVERSICHERUNG', art: 'LEBENSVERSICHERUNG',
status: 'AKTIV', status: 'AKTIV',
vertragsnummer: '', vertragsnummer: '',
beginnAm: '', beginnAm: '',
endeAm: '', endeAm: '',
kuendigungsfristMonate: '3', kuendigungsfristMonate: '3',
kuendigungZum: '', kuendigungZum: '',
gekuendigtAm: '', gekuendigtAm: '',
beitrag: '', beitrag: '',
rhythmus: 'MONATLICH', rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT', bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '', bewertungBegruendung: '',
notizen: '', notizen: '',
} }
export function VertragFormular({ export function VertragFormular({
betreuteId, betreuteId,
werte, werte,
}: { }: {
betreuteId: string betreuteId: string
werte: VertragWerte werte: VertragWerte
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe) const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id const istNeu = !werte.id
return ( return (
<form action={aktion} className="space-y-8" noValidate> <form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} /> <input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />} {werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} /> <Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer."> <Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld <Feld
id="bezeichnung" id="bezeichnung"
beschriftung="Bezeichnung" beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst." erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung} fehler={zustand.felder?.bezeichnung}
pflicht pflicht
> >
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} /> <Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld> </Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}> <Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} /> <Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld> </Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht> <Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}> <Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => ( {VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}> <option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]} {VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}> <Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}> <Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => ( {VERTRAG_STATUS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]} {VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="vertragsnummer" id="vertragsnummer"
beschriftung="Vertragsnummer" beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu." erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer} fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} /> <Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Laufzeit und Kündigung" titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag." beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
> >
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}> <Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} /> <Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld> </Feld>
<Feld <Feld
id="endeAm" id="endeAm"
beschriftung="Vereinbartes Ende" beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet." erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
fehler={zustand.felder?.endeAm} fehler={zustand.felder?.endeAm}
> >
<Input name="endeAm" type="date" defaultValue={werte.endeAm} /> <Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld> </Feld>
<Feld <Feld
id="kuendigungsfristMonate" id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten" beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate." erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate} fehler={zustand.felder?.kuendigungsfristMonate}
> >
<Input <Input
name="kuendigungsfristMonate" name="kuendigungsfristMonate"
inputMode="numeric" inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate} defaultValue={werte.kuendigungsfristMonate}
/> />
</Feld> </Feld>
<Feld <Feld
id="kuendigungZum" id="kuendigungZum"
beschriftung="Gekündigt zum" beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum} fehler={zustand.felder?.kuendigungZum}
> >
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} /> <Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld> </Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}> <Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} /> <Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht."> <Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}> <Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" /> <Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld> </Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}> <Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}> <Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option> <option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => ( {ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]} {RHYTHMUS_BESCHRIFTUNG[r]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Bewertung fürs Sozialamt" titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft." beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
> >
<Feld <Feld
id="bewertungSozialhilfe" id="bewertungSozialhilfe"
beschriftung="Einordnung" beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]} erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe} fehler={zustand.felder?.bewertungSozialhilfe}
> >
<Select <Select
name="bewertungSozialhilfe" name="bewertungSozialhilfe"
value={bewertung} value={bewertung}
onChange={(e) => setBewertung(e.target.value)} onChange={(e) => setBewertung(e.target.value)}
> >
{BEWERTUNGEN.map((b) => ( {BEWERTUNGEN.map((b) => (
<option key={b} value={b}> <option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]} {BEWERTUNG_BESCHRIFTUNG[b]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="bewertungBegruendung" id="bewertungBegruendung"
beschriftung="Begründung" beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren." erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung} fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'} pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Textarea <Textarea
name="bewertungBegruendung" name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung} defaultValue={werte.bewertungBegruendung}
rows={4} rows={4}
maxLength={4000} maxLength={4000}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld <Feld
id="notizen" id="notizen"
beschriftung="Notizen" beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert." erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen} fehler={zustand.felder?.notizen}
> >
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} /> <Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld> </Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf> <Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form> </form>
) )
} }
+213 -213
View File
@@ -1,213 +1,213 @@
import 'server-only' import 'server-only'
import { import {
AlignmentType, AlignmentType,
BorderStyle, BorderStyle,
Document, Document,
Footer, Footer,
Header, Header,
HeadingLevel, HeadingLevel,
PageNumber, PageNumber,
Packer, Packer,
Paragraph, Paragraph,
TextRun, TextRun,
convertMillimetersToTwip, convertMillimetersToTwip,
} from 'docx' } from 'docx'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf' import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/** /**
* DOCX-Ausgabe. * DOCX-Ausgabe.
* *
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal * Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen * fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als * Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine * Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs. * .docx-Datei schon - in Word, LibreOffice oder Google Docs.
* *
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die * Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung. * Arbeitsfassung.
* *
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus * Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann * src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind * funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein. * dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/ */
export type DocxKopf = { export type DocxKopf = {
titel: string titel: string
betreute: { name: string; geburtsdatum: Date | null } betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string behoerde?: string
aktenzeichen?: string aktenzeichen?: string
zeitraumVon?: Date | null zeitraumVon?: Date | null
zeitraumBis?: Date | null zeitraumBis?: Date | null
stichtag?: Date | null stichtag?: Date | null
rechtsgrundlage?: string rechtsgrundlage?: string
erstelltAm: Date erstelltAm: Date
erstelltVon: string erstelltVon: string
} }
const SCHRIFT = 'Arial' const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) { function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({ return new TextRun({
text, text,
bold: optionen.fett ?? false, bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000', color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT, font: SCHRIFT,
}) })
} }
function kopfzeilen(kopf: DocxKopf): string[] { function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [ const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` + `Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''), (kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
] ]
if (kopf.behoerde) zeilen.push(kopf.behoerde) if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`) if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) { if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push( zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`, `Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
) )
} }
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`) if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`) if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen return zeilen
} }
function unterschriftenblock(kopf: DocxKopf): Paragraph[] { function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [ return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }), new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({ new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)], children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 }, spacing: { after: 500 },
}), }),
new Paragraph({ new Paragraph({
children: [zeile('')], children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } }, border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 }, spacing: { after: 60 },
}), }),
new Paragraph({ new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })], children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}), }),
] ]
} }
/** /**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften * Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am * erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF. * Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/ */
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document { export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text) const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [ const inhalt: Paragraph[] = [
new Paragraph({ new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })], children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 }, spacing: { after: 160 },
}), }),
...kopfzeilen(kopf).map( ...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }), (text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
), ),
new Paragraph({ new Paragraph({
children: [zeile('')], children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } }, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 }, spacing: { after: 200 },
}), }),
] ]
for (const abschnitt of abschnitte) { for (const abschnitt of abschnitte) {
if (abschnitt.titel) { if (abschnitt.titel) {
inhalt.push( inhalt.push(
new Paragraph({ new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })], children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2, heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 }, spacing: { before: 260, after: 100 },
}), }),
) )
} }
for (const absatz of abschnitt.absaetze) { for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•') const istAufzaehlung = absatz.startsWith('•')
inhalt.push( inhalt.push(
new Paragraph({ new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)], children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined, bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED, alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 }, spacing: { after: 120, line: 300 },
}), }),
) )
} }
} }
inhalt.push(...unterschriftenblock(kopf)) inhalt.push(...unterschriftenblock(kopf))
return new Document({ return new Document({
creator: kopf.erstelltVon, creator: kopf.erstelltVon,
title: kopf.titel, title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`, description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [ sections: [
{ {
properties: { properties: {
page: { page: {
margin: { margin: {
top: convertMillimetersToTwip(25), top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20), bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25), left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20), right: convertMillimetersToTwip(20),
}, },
}, },
}, },
headers: { headers: {
default: new Header({ default: new Header({
children: [ children: [
new Paragraph({ new Paragraph({
alignment: AlignmentType.RIGHT, alignment: AlignmentType.RIGHT,
children: [ children: [
zeile( zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`, `${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true }, { groesse: 8, grau: true },
), ),
], ],
}), }),
], ],
}), }),
}, },
footers: { footers: {
default: new Footer({ default: new Footer({
children: [ children: [
new Paragraph({ new Paragraph({
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
children: [ children: [
zeile('Seite ', { groesse: 8, grau: true }), zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({ new TextRun({
children: [PageNumber.CURRENT], children: [PageNumber.CURRENT],
size: 16, size: 16,
color: '555555', color: '555555',
font: SCHRIFT, font: SCHRIFT,
}), }),
zeile(' von ', { groesse: 8, grau: true }), zeile(' von ', { groesse: 8, grau: true }),
new TextRun({ new TextRun({
children: [PageNumber.TOTAL_PAGES], children: [PageNumber.TOTAL_PAGES],
size: 16, size: 16,
color: '555555', color: '555555',
font: SCHRIFT, font: SCHRIFT,
}), }),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, { zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8, groesse: 8,
grau: true, grau: true,
}), }),
], ],
}), }),
], ],
}), }),
}, },
children: inhalt, children: inhalt,
}, },
], ],
}) })
} }
/** Fertige .docx-Datei als Buffer. */ /** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> { export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text)) return Packer.toBuffer(docxAusEntwurf(kopf, text))
} }
+214 -214
View File
@@ -1,214 +1,214 @@
import 'server-only' import 'server-only'
import type * as React from 'react' import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib' import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp' import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/** /**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen. * PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
* *
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
* *
* Zwei Wege fuehren hier zusammen: * Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte) * - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander * - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht) * (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das * und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann. * ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/ */
/** A4 in PDF-Punkten (72 dpi). */ /** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28 export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89 export const A4_HOEHE = 841.89
const RAND = 36 const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */ /** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> { export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die // Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem // Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts. // zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen() standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von // renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement. // @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0]) const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh) return Buffer.from(roh)
} }
/** /**
* Wandelt ein Bild in ein einseitiges PDF. * Wandelt ein Bild in ein einseitiges PDF.
* *
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die * JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere * Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren. * jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/ */
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> { export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt) const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) { if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.') throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
} }
let daten = inhalt let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png' let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') { if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer() daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png' format = 'png'
} }
const pdf = await PDFDocument.create() const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten) const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0 const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde. // Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1) const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab const breite = bild.width * massstab
const hoehe = bild.height * massstab const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe]) const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, { seite.drawImage(bild, {
x: (seitenBreite - breite) / 2, x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2, y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite, width: breite,
height: hoehe, height: hoehe,
}) })
if (beschriftung) { if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica) const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), { seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND, x: RAND,
y: RAND / 2, y: RAND / 2,
size: 8, size: 8,
font: schrift, font: schrift,
color: rgb(0.3, 0.3, 0.3), color: rgb(0.3, 0.3, 0.3),
}) })
} }
return Buffer.from(await pdf.save()) return Buffer.from(await pdf.save())
} }
/** /**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket). * Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib. * Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/ */
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> { export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create() const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit') ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit') ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) { for (const teil of teile) {
const typ = typErkennen(teil) const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true }) const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices()) const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite) for (const seite of seiten) ziel.addPage(seite)
} }
if (ziel.getPageCount() === 0) { if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften. // Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE]) ziel.addPage([A4_BREITE, A4_HOEHE])
} }
return Buffer.from(await ziel.save()) return Buffer.from(await ziel.save())
} }
/** /**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der * Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird. * Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/ */
export async function seitenzahl(pdf: Buffer): Promise<number> { export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount() return dokument.getPageCount()
} }
/** /**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen. * Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
* *
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest, * Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die * auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen. * sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/ */
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> { export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica) const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages() const seiten = dokument.getPages()
const gesamt = seiten.length const gesamt = seiten.length
seiten.forEach((seite, i) => { seiten.forEach((seite, i) => {
const { width, height } = seite.getSize() const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360 const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}` const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120) const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5 const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls // Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt. // gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) { if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, { seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18, x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20, y: drehung === 90 ? 20 : height - 20,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung), rotate: degrees(drehung),
}) })
return return
} }
seite.drawText(text, { seite.drawText(text, {
x: 36, x: 36,
y: 18, y: 18,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
}) })
const breite = schrift.widthOfTextAtSize(zaehlung, groesse) const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, { seite.drawText(zaehlung, {
x: width - 36 - breite, x: width - 36 - breite,
y: 18, y: 18,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
}) })
}) })
return Buffer.from(await dokument.save()) return Buffer.from(await dokument.save())
} }
/** /**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa * Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen - * ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren. * deshalb hier ersetzen statt riskieren.
*/ */
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string { export function fuerStandardschrift(text: string): string {
return text return text
.replace(/[\u2013\u2014]/g, '-') .replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'") .replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"') .replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...') .replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR') .replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ') .replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das // Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen. // Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?') .replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
} }
function kuerzenFuerSchrift(text: string, maxZeichen: number): string { function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text) const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
} }
+237 -237
View File
@@ -1,237 +1,237 @@
/** /**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens. * Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
* *
* ACHTUNG - EINZIGE STELLE. * ACHTUNG - EINZIGE STELLE.
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert, * Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und * steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler * die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden. * gefunden.
* *
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für * Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der * die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel * Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft. * gehören die Werte hier nachgeprüft.
*/ */
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */ /** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01' export const KONSTANTEN_STAND = '2025-01-01'
/** /**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche * Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein. * blendet ab diesem Datum einen Prüfhinweis ein.
*/ */
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01' export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung // Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung. * Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die * Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung). * Fortschreibung ergab rechnerisch keine Erhöhung).
* *
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze * Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht * der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt. * doppelt gepflegt.
*/ */
export const REGELBEDARFSSTUFE_1_CENT = 56_300n export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/** /**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in * Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der * Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1. * Regelbedarfsstufe 1.
* *
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025). * 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
* *
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun * "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid. * das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/ */
export const BARBETRAG_ANTEIL_PROZENT = 27 export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/** /**
* Bekleidungspauschale. * Bekleidungspauschale.
* *
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die * Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger * Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je * fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen * Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier * und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine. * schädlicher als gar keine.
*/ */
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS = export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' + 'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.' 'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI // Leistungen der Pflegekasse, § 43 SGB XI
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5 export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/** /**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege, * Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das * § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz). * Pflegeunterstützungs- und -entlastungsgesetz).
* *
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht. * Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach * Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht * § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage. * er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
* *
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für * Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen. * 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/ */
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = { export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale 1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n, 2: 80_500n,
3: 131_900n, 3: 131_900n,
4: 185_500n, 4: 185_500n,
5: 209_600n, 5: 209_600n,
} }
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = { export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI', 1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI', 2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI', 3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI', 4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI', 5: '§ 43 Abs. 2 SGB XI',
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI // Leistungszuschlag, § 43c SGB XI
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// //
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach // STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in // Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber // src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien // src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung // derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen. // zuverlaessig nur an einer Stelle nachgezogen.
// //
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den // Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung, // pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf // nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein. // die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub) // Abwesenheit (Krankenhaus, Reha, Urlaub)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag. * Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
* *
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten * Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten * drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern * zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung. * üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten * Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr). * (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
* *
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und * Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was * nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung. * gilt, steht in der Heimrechnung.
*/ */
export const ABWESENHEIT_KARENZTAGE = 3 export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25 export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42 export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Vermögen, § 90 SGB XII // Vermögen, § 90 SGB XII
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII * Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des * i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR. * § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
* *
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der * Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
* leistungsberechtigten Person überwiegend unterhalten wird, kommen * leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung). * weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/ */
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01' export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/** /**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB. * Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der * Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre * Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach * verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre. * notariellen Übertragungen der letzten zehn Jahre.
*/ */
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10 export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Rechtsbehelf // Rechtsbehelf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */ /** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1 export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/** /**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt * Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG). * die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen. * Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/ */
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12 export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/** /**
* Zugangsvermutung bei Übermittlung durch die Post im Inland: * Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post * bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X). * (§ 37 Abs. 2 Satz 1 SGB X).
*/ */
export const ZUGANGSVERMUTUNG_TAGE = 3 export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */ /** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1] export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */ /** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1] export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Weitere Leistungen // Weitere Leistungen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2, * Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches * Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG). * Landespflegegeldgesetz - BayLPflGG).
* *
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon * Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe * mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen. * beim Landesamt für Pflege prüfen.
*/ */
export const LANDESPFLEGEGELD_CENT = 100_000n export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2 export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.' export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/** /**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der * Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend * jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %. * chronisch Kranken bei 1 %.
* *
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung * Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1 * erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche * anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* Rente. Das macht die Befreiung meist schon im Januar erreichbar. * Rente. Das macht die Befreiung meist schon im Januar erreichbar.
* *
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025). * 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
*/ */
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2 export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1 export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT = export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n (REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT = export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n (REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/** /**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag: * Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein * 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR. * Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
*/ */
export const RUNDFUNKBEITRAG_CENT = 1_836n export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01' export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
+437 -437
View File
@@ -1,437 +1,437 @@
import { z } from 'zod' import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag' import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/** /**
* Eingabeprüfung für das Sozialhilfemodul. * Eingabeprüfung für das Sozialhilfemodul.
* *
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist - * Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine * nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input". * Nachforderung erfasst, hat keine Lust auf "invalid input".
*/ */
const optionalerText = (max = 200) => const optionalerText = (max = 200) =>
z z
.string() .string()
.trim() .trim()
.max(max, `Bitte höchstens ${max} Zeichen.`) .max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
const optionalesDatum = z const optionalesDatum = z
.string() .string()
.trim() .trim()
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), { .refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
}) })
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`))) .transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z const pflichtDatum = z
.string() .string()
.trim() .trim()
.min(1, 'Bitte ein Datum angeben.') .min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), { .refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
}) })
.transform((wert) => new Date(`${wert}T00:00:00.000Z`)) .transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/** /**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine * Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei. * Angabe" und "null Euro" sind fachlich zweierlei.
*/ */
const optionalerBetrag = z const optionalerBetrag = z
.string() .string()
.trim() .trim()
.default('') .default('')
.transform((wert, ctx) => { .transform((wert, ctx) => {
if (wert === '') return null if (wert === '') return null
const cent = betragParsenWennMoeglich(wert) const cent = betragParsenWennMoeglich(wert)
if (cent === null) { if (cent === null) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.', message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
}) })
return z.NEVER return z.NEVER
} }
return cent return cent
}) })
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */ /** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z const betragOderNull = z
.string() .string()
.trim() .trim()
.default('') .default('')
.transform((wert, ctx) => { .transform((wert, ctx) => {
if (wert === '') return 0n if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert) const cent = betragParsenWennMoeglich(wert)
if (cent === null) { if (cent === null) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.', message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
}) })
return z.NEVER return z.NEVER
} }
if (cent < 0n) { if (cent < 0n) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.', message: 'In dieser Aufstellung stehen nur positive Beträge.',
}) })
return z.NEVER return z.NEVER
} }
return cent return cent
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Antragsakte // Antragsakte
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const AntragSchema = z export const AntragSchema = z
.object({ .object({
traeger: z traeger: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.') .min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150), .max(150),
leistungsart: z leistungsart: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte die Leistungsart angeben.') .min(2, 'Bitte die Leistungsart angeben.')
.max(200), .max(200),
aktenzeichen: optionalerText(80), aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150), sachbearbeiter: optionalerText(150),
telefon: optionalerText(50), telefon: optionalerText(50),
email: z email: z
.string() .string()
.trim() .trim()
.max(200) .max(200)
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), { .refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.', message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}), }),
antragAm: optionalesDatum, antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum, eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum, bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum, bewilligtBis: optionalesDatum,
notizen: optionalerText(4000), notizen: optionalerText(4000),
}) })
.refine( .refine(
(daten) => (daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis, !daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{ {
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.', message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'], path: ['bewilligtBis'],
}, },
) )
.refine( .refine(
(daten) => (daten) =>
!daten.antragAm || !daten.antragAm ||
!daten.eingangBeimTraegerAm || !daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm, daten.antragAm <= daten.eingangBeimTraegerAm,
{ {
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.', message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'], path: ['eingangBeimTraegerAm'],
}, },
) )
export type AntragEingabe = z.infer<typeof AntragSchema> export type AntragEingabe = z.infer<typeof AntragSchema>
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Nachforderung // Nachforderung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [ export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN', 'OFFEN',
'IN_BESCHAFFUNG', 'IN_BESCHAFFUNG',
'UEBERSANDT', 'UEBERSANDT',
'ANERKANNT', 'ANERKANNT',
'NICHT_VORHANDEN', 'NICHT_VORHANDEN',
] as const ] as const
export const NachforderungSchema = z export const NachforderungSchema = z
.object({ .object({
bezeichnung: z bezeichnung: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.') .min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200), .max(200),
erlaeuterung: optionalerText(2000), erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'), status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum, angefordertAm: optionalesDatum,
fristBis: optionalesDatum, fristBis: optionalesDatum,
uebersandtAm: optionalesDatum, uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000), begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0), reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40), dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */ /** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false), fristAnlegen: z.coerce.boolean().default(false),
}) })
.refine( .refine(
(daten) => (daten) =>
daten.status !== 'NICHT_VORHANDEN' || daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null && (daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5), daten.begruendungNichtVorhanden.trim().length >= 5),
{ {
message: message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' + 'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.', 'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'], path: ['begruendungNichtVorhanden'],
}, },
) )
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, { .refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message: message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' + 'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.', 'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'], path: ['uebersandtAm'],
}) })
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, { .refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.', message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'], path: ['fristBis'],
}) })
export type NachforderungEingabe = z.infer<typeof NachforderungSchema> export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z export const NachforderungStatusSchema = z
.object({ .object({
nachforderungId: z.string().trim().min(1), nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE), status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000), begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum, uebersandtAm: optionalesDatum,
}) })
.refine( .refine(
(daten) => (daten) =>
daten.status !== 'NICHT_VORHANDEN' || daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null && (daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5), daten.begruendungNichtVorhanden.trim().length >= 5),
{ {
message: 'Bitte begründen, warum es die Unterlage nicht gibt.', message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'], path: ['begruendungNichtVorhanden'],
}, },
) )
export const ChecklisteSchema = z.object({ export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1), antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'), schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum, angefordertAm: optionalesDatum,
fristBis: optionalesDatum, fristBis: optionalesDatum,
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Bescheid // Bescheid
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [ export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG', 'BEWILLIGUNG',
'ABLEHNUNG', 'ABLEHNUNG',
'AENDERUNG', 'AENDERUNG',
'AUFHEBUNG', 'AUFHEBUNG',
'ERSTATTUNG', 'ERSTATTUNG',
'VORSCHUSS', 'VORSCHUSS',
'WIDERSPRUCHSBESCHEID', 'WIDERSPRUCHSBESCHEID',
] as const ] as const
export const PRUEFSTATUS_WERTE = [ export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT', 'UNGEPRUEFT',
'GEPRUEFT_OK', 'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT', 'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT', 'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN', 'FRIST_VERSTRICHEN',
] as const ] as const
export const BescheidSchema = z export const BescheidSchema = z
.object({ .object({
art: z.enum(BESCHEID_ART_WERTE), art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum, bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum, zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum, zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum, zeitraumBis: optionalesDatum,
betrag: optionalerBetrag, betrag: optionalerBetrag,
/** /**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte * 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten. * Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/ */
rechtsbehelfsfristMonate: z.coerce rechtsbehelfsfristMonate: z.coerce
.number() .number()
.int() .int()
.refine((wert) => wert === 1 || wert === 12, { .refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.', message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
}) })
.default(1), .default(1),
widerspruchEingelegtAm: optionalesDatum, widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000), notizen: optionalerText(4000),
dokumentId: optionalerText(40), dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */ /** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false), fristAnlegen: z.coerce.boolean().default(false),
}) })
.refine( .refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom, (daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{ {
message: message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' + 'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.', 'Poststempel.',
path: ['zugegangenAm'], path: ['zugegangenAm'],
}, },
) )
.refine( .refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis, (daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] }, { message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
) )
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, { .refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message: message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' + 'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.', 'keine Frist anlegen.',
path: ['zugegangenAm'], path: ['zugegangenAm'],
}) })
export type BescheidEingabe = z.infer<typeof BescheidSchema> export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({ export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1), bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE), pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum, widerspruchEingelegtAm: optionalesDatum,
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Vermögensbewertung // Vermögensbewertung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z export const BewertungSchema = z
.object({ .object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']), herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1), id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE), bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000), begruendung: optionalerText(6000),
}) })
.refine( .refine(
(daten) => (daten) =>
daten.bewertung === 'UNGEPRUEFT' || daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10), (daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{ {
message: message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' + 'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.', 'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'], path: ['begruendung'],
}, },
) )
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Heimkosten // Heimkosten
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({ export const HeimkostenSchema = z.object({
jahr: z.coerce jahr: z.coerce
.number() .number()
.int() .int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.') .min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'), .max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12), monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull, pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull, unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull, investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull, ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull, zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull, pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce leistungszuschlagProzent: z.coerce
.number() .number()
.int() .int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0), .default(0),
leistungszuschlag: betragOderNull, leistungszuschlag: betragOderNull,
rente: betragOderNull, rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull, sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull, sozialhilfe: betragOderNull,
eigenmittel: betragOderNull, eigenmittel: betragOderNull,
barbetrag: betragOderNull, barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull, bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce abwesenheitstage: z.coerce
.number() .number()
.int() .int()
.min(0, 'Abwesenheitstage können nicht negativ sein.') .min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.') .max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0), .default(0),
bemerkung: optionalerText(2000), bemerkung: optionalerText(2000),
}) })
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema> export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Weitere Leistungen // Weitere Leistungen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [ export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT', 'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT', 'PRUEFUNG_LAEUFT',
'BEANTRAGT', 'BEANTRAGT',
'BEWILLIGT', 'BEWILLIGT',
'ABGELEHNT', 'ABGELEHNT',
'LAEUFT', 'LAEUFT',
'NICHT_ZUTREFFEND', 'NICHT_ZUTREFFEND',
] as const ] as const
export const LeistungSchema = z export const LeistungSchema = z
.object({ .object({
leistungId: optionalerText(40), leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80), katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200), bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200), traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200), rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'), status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum, beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum, bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum, gueltigBis: optionalesDatum,
betrag: optionalerBetrag, betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false), jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000), notizen: optionalerText(2000),
}) })
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, { .refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.', message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'], path: ['bewilligtAm'],
}) })
export const KatalogUebernehmenSchema = z.object({ export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'), schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Formularhilfen // Formularhilfen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */ /** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean { export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name) const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1' return wert === 'on' || wert === 'true' || wert === '1'
} }
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */ /** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> { export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {} const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) { for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert if (typeof wert === 'string') roh[name] = wert
} }
return roh return roh
} }
/** /**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten. * Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/ */
export function feldfehler(fehler: z.ZodError): Record<string, string> { export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {} const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) { for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0] const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste if (erste) ergebnis[feld] = erste
} }
return ergebnis return ergebnis
} }
+171 -171
View File
@@ -1,171 +1,171 @@
import { z } from 'zod' import { z } from 'zod'
import { import {
optionalerBetrag, optionalerBetrag,
optionalerText, optionalerText,
optionaleZahl, optionaleZahl,
optionalesDatum, optionalesDatum,
pflichtBetrag, pflichtBetrag,
pflichtDatum, pflichtDatum,
pflichtText, pflichtText,
} from '@/lib/validierung/substanz' } from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten' import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/** /**
* Eingabepruefung fuer Versicherungen und Vertraege. * Eingabepruefung fuer Versicherungen und Vertraege.
*/ */
export const VERTRAG_ARTEN = [ export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG', 'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG', 'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE', 'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT', 'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG', 'UNFALLVERSICHERUNG',
'HAFTPFLICHT', 'HAFTPFLICHT',
'HAUSRAT', 'HAUSRAT',
'WOHNGEBAEUDE', 'WOHNGEBAEUDE',
'KRANKENZUSATZ', 'KRANKENZUSATZ',
'PFLEGEZUSATZ', 'PFLEGEZUSATZ',
'RECHTSSCHUTZ', 'RECHTSSCHUTZ',
'STERBEKASSE', 'STERBEKASSE',
'STROM', 'STROM',
'GAS', 'GAS',
'WASSER', 'WASSER',
'TELEFON_INTERNET', 'TELEFON_INTERNET',
'RUNDFUNKBEITRAG', 'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT', 'MITGLIEDSCHAFT',
'ABONNEMENT', 'ABONNEMENT',
'SONSTIGES', 'SONSTIGES',
] as const ] as const
export const VERTRAG_STATUS = [ export const VERTRAG_STATUS = [
'AKTIV', 'AKTIV',
'BEITRAGSFREI', 'BEITRAGSFREI',
'RUHEND', 'RUHEND',
'GEKUENDIGT', 'GEKUENDIGT',
'ABGETRETEN', 'ABGETRETEN',
'VERWERTET', 'VERWERTET',
'ABGELAUFEN', 'ABGELAUFEN',
] as const ] as const
export const ZAHL_RHYTHMEN = [ export const ZAHL_RHYTHMEN = [
'MONATLICH', 'MONATLICH',
'VIERTELJAEHRLICH', 'VIERTELJAEHRLICH',
'HALBJAEHRLICH', 'HALBJAEHRLICH',
'JAEHRLICH', 'JAEHRLICH',
'EINMALIG', 'EINMALIG',
] as const ] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z export const VertragSchema = z
.object({ .object({
bezeichnung: pflichtText( bezeichnung: pflichtText(
150, 150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.', 'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
), ),
gesellschaft: optionalerText(150), gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN), art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'), status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */ /** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80), vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum, beginnAm: optionalesDatum,
endeAm: optionalesDatum, endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl( kuendigungsfristMonate: optionaleZahl(
0, 0,
120, 120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.', 'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
), ),
kuendigungZum: optionalesDatum, kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum, gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag, beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null), rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'), bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000), bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000), notizen: optionalerText(4000),
}) })
.superRefine((daten, kontext) => { .superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) { if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['endeAm'], path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.', message: 'Das Vertragsende liegt vor dem Beginn.',
}) })
} }
if (daten.beitrag !== null && !daten.rhythmus) { if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['rhythmus'], path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.', message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
}) })
} }
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) { if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'], path: ['bewertungBegruendung'],
message: message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' + 'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.', 'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
}) })
} }
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) { if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'], path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.', message: 'Bitte das Datum der Kündigung angeben.',
}) })
} }
}) })
export type VertragEingabe = z.infer<typeof VertragSchema> export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */ /** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({ export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum, stichtag: pflichtDatum,
wert: pflichtBetrag, wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, { wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }), errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}), }),
bemerkung: optionalerText(1000), bemerkung: optionalerText(1000),
}) })
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema> export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({ export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum, stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500), empfaengerAnschrift: optionalerText(500),
}) })
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema> export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/** /**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt * Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der * sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat. * gekündigt hat.
*/ */
export const KuendigungVermerkenSchema = z export const KuendigungVermerkenSchema = z
.object({ .object({
gekuendigtAm: pflichtDatum, gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum, kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'), versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false), nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false), genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000), bemerkung: optionalerText(2000),
}) })
.superRefine((daten, kontext) => { .superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) { if (!daten.nachweisVorhanden) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'], path: ['nachweisVorhanden'],
message: message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' + 'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.', 'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
}) })
} }
}) })
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema> export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
+227 -227
View File
@@ -1,227 +1,227 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/** /**
* Kuendigungsfrist-Rechner. * Kuendigungsfrist-Rechner.
* *
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der * Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus * Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht * muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist * mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr. * verpasst und zahlt ein weiteres Jahr.
* *
* Deshalb rechnet diese Datei drei Daten aus: * Deshalb rechnet diese Datei drei Daten aus:
* *
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden), * 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss, * 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer. * 3. den spaetesten Absendetag mit Postlaufpuffer.
* *
* Reine Logik, keine Datenbank. * Reine Logik, keine Datenbank.
*/ */
/** /**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber * Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht, * bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den * schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da. * Zugang und ist am selben Tag da.
*/ */
export const POSTLAUF_PUFFER_TAGE = 3 export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */ /** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30 export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/** /**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr * Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG). * ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/ */
export const STANDARD_LAUFZEIT_JAHRE = 1 export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = { export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */ /** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */ /** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */ /** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */ /** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */ /** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */ /** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date heute?: Date
} }
export type Kuendigungsplan = { export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */ /** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */ /** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */ /** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */ /** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */ /** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */ /** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */ /** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */ /** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[] hinweise: string[]
} }
const LEERER_PLAN: Kuendigungsplan = { const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false, berechenbar: false,
naechsterTermin: null, naechsterTermin: null,
zugangSpaetestensAm: null, zugangSpaetestensAm: null,
spaetesterAbsendetag: null, spaetesterAbsendetag: null,
tageBisAbsendetag: null, tageBisAbsendetag: null,
vorwarnung: false, vorwarnung: false,
verpasst: false, verpasst: false,
hinweise: [], hinweise: [],
} }
/** /**
* Berechnet den naechstmoeglichen Kuendigungstermin. * Berechnet den naechstmoeglichen Kuendigungstermin.
* *
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige * Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine * Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig * Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche. * rausgeht, ist der naechstmoegliche.
*/ */
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan { export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag()) const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = [] const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) { if (frist === null || frist === undefined) {
hinweise.push( hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' + 'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.', 'bitte im Vertrag nachsehen und nachtragen.',
) )
} }
const fristMonate = frist ?? 0 const fristMonate = frist ?? 0
const laufzeit = const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0 eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre ? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE : STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) { if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push( hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` + `Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`, `${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
) )
} }
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit. // Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null let termin: Date | null = null
if (eingabe.endeAm) { if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm) termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) { } else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit) termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
} }
if (!termin) { if (!termin) {
return { return {
...LEERER_PLAN, ...LEERER_PLAN,
hinweise: [ hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' + 'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.', 'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
], ],
} }
} }
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von // Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen. // 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate) let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0 let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) { while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit) termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate) absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1 durchlaeufe += 1
} }
const zugang = plusMonate(termin, -fristMonate) const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag) const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime() const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) { if (verpasst) {
hinweise.push( hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` + `Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` + `Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`, `Termin ist nicht mehr möglich.`,
) )
} else if (durchlaeufe > 0) { } else if (durchlaeufe > 0) {
hinweise.push( hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` + `Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`, `nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
) )
} }
hinweise.push( hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` + `Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` + `sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` + `eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`, `ein Fax mit Sendeprotokoll.`,
) )
return { return {
berechenbar: true, berechenbar: true,
naechsterTermin: termin, naechsterTermin: termin,
zugangSpaetestensAm: zugang, zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag, spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis, tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE, vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst, verpasst,
hinweise, hinweise,
} }
} }
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */ /** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date { export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE) return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
} }
/** /**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag. * Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben. * Farbe ist nie die einzige Information - der Text steht daneben.
*/ */
export type Kuendigungsampel = { export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen' variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string text: string
} }
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null { export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) { if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' } return { variante: 'rot', text: 'Absendetag verstrichen' }
} }
const tage = plan.tageBisAbsendetag const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` } if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) { if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` } return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
} }
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` } if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` } return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
} }
/** /**
* Die Vertragsdauer aus Beginn und vereinbartem Ende. * Die Vertragsdauer aus Beginn und vereinbartem Ende.
* *
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe * Das Datenmodell kennt kein Feld "Laufzeit" (siehe
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im * docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis * Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um * 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage, * dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die * ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
* Spanne unter einem halben Jahr, bleibt es beim Standard. * Spanne unter einem halben Jahr, bleibt es beim Standard.
*/ */
export function laufzeitAusVertrag( export function laufzeitAusVertrag(
beginnAm: Date | null, beginnAm: Date | null,
endeAm: Date | null, endeAm: Date | null,
): number | null { ): number | null {
if (!beginnAm || !endeAm) return null if (!beginnAm || !endeAm) return null
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25) const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
return jahre >= 1 ? jahre : null return jahre >= 1 ? jahre : null
} }
File diff suppressed because it is too large Load Diff
+385 -385
View File
@@ -1,385 +1,385 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client' import type { Modul } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { passwortHashen } from '@/lib/crypto/hash' import { passwortHashen } from '@/lib/crypto/hash'
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung' import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen' import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import { import {
benutzerAktivSchema, benutzerAktivSchema,
benutzerAnlegenSchema, benutzerAnlegenSchema,
darfBenutzerVerwalten, darfBenutzerVerwalten,
darfFreigabenVergeben, darfFreigabenVergeben,
freigabeAenderung, freigabeAenderung,
freigabenNormalisieren, freigabenNormalisieren,
freigabenSchema, freigabenSchema,
letzterAdminGeschuetzt, letzterAdminGeschuetzt,
rolleAendernSchema, rolleAendernSchema,
sitzungenBeendenSchema, sitzungenBeendenSchema,
} from '@/lib/validierung/freigaben' } from '@/lib/validierung/freigaben'
/** /**
* Benutzerverwaltung und Modulfreigaben. * Benutzerverwaltung und Modulfreigaben.
* *
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem * Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was * Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend: * ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die * frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer * betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
* wem wann was freigegeben hat. * wem wann was freigegeben hat.
*/ */
export type Aktionszustand = { export type Aktionszustand = {
fehler?: string fehler?: string
felder?: Record<string, string> felder?: Record<string, string>
erfolg?: string erfolg?: string
warnung?: string warnung?: string
} }
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> { function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
const felder: Record<string, string> = {} const felder: Record<string, string> = {}
for (const problem of fehler) { for (const problem of fehler) {
const schluessel = String(problem.path[0] ?? 'formular') const schluessel = String(problem.path[0] ?? 'formular')
if (!felder[schluessel]) felder[schluessel] = problem.message if (!felder[schluessel]) felder[schluessel] = problem.message
} }
return felder return felder
} }
async function administratorSein(weiter: string) { async function administratorSein(weiter: string) {
const benutzer = await erfordereBenutzer(weiter) const benutzer = await erfordereBenutzer(weiter)
if (!darfBenutzerVerwalten(benutzer.rolle)) { if (!darfBenutzerVerwalten(benutzer.rolle)) {
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.') throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
} }
await erfordereFrischeAnmeldung(benutzer, weiter) await erfordereFrischeAnmeldung(benutzer, weiter)
return benutzer return benutzer
} }
async function anzahlAktiverAdmins(): Promise<number> { async function anzahlAktiverAdmins(): Promise<number> {
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } }) return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Benutzer // Benutzer
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export async function benutzerAnlegen( export async function benutzerAnlegen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = benutzerAnlegenSchema.safeParse({ const gelesen = benutzerAnlegenSchema.safeParse({
name: formular.get('name'), name: formular.get('name'),
email: formular.get('email'), email: formular.get('email'),
rolle: formular.get('rolle'), rolle: formular.get('rolle'),
passwort: formular.get('passwort'), passwort: formular.get('passwort'),
}) })
if (!gelesen.success) { if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) } return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
} }
const admin = await administratorSein('/einstellungen/benutzer') const admin = await administratorSein('/einstellungen/benutzer')
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } }) const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
if (vorhanden) { if (vorhanden) {
return { return {
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.', fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
felder: { email: 'Bereits vergeben.' }, felder: { email: 'Bereits vergeben.' },
} }
} }
const neu = await db.user.create({ const neu = await db.user.create({
data: { data: {
name: gelesen.data.name, name: gelesen.data.name,
email: gelesen.data.email, email: gelesen.data.email,
rolle: gelesen.data.rolle, rolle: gelesen.data.rolle,
passwortHash: await passwortHashen(gelesen.data.passwort), passwortHash: await passwortHashen(gelesen.data.passwort),
}, },
}) })
await protokollieren({ await protokollieren({
aktion: 'ERSTELLT', aktion: 'ERSTELLT',
entitaet: 'User', entitaet: 'User',
entitaetId: neu.id, entitaetId: neu.id,
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`, bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
userId: admin.id, userId: admin.id,
userEmail: admin.email, userEmail: admin.email,
userName: admin.name, userName: admin.name,
}) })
revalidatePath('/einstellungen/benutzer') revalidatePath('/einstellungen/benutzer')
return { return {
erfolg: erfolg:
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` + `${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
`werden – ohne ihn kommt niemand an Fachdaten.`, `werden – ohne ihn kommt niemand an Fachdaten.`,
} }
} }
export async function rolleAendern( export async function rolleAendern(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = rolleAendernSchema.safeParse({ const gelesen = rolleAendernSchema.safeParse({
userId: formular.get('userId'), userId: formular.get('userId'),
rolle: formular.get('rolle'), rolle: formular.get('rolle'),
}) })
if (!gelesen.success) { if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) } return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
} }
const admin = await administratorSein('/einstellungen/benutzer') const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } }) const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' } if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' } if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
const schutz = letzterAdminGeschuetzt({ const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle, zielRolleVorher: ziel.rolle,
zielRolleNachher: gelesen.data.rolle, zielRolleNachher: gelesen.data.rolle,
zielBleibtAktiv: ziel.aktiv, zielBleibtAktiv: ziel.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(), anzahlAktiverAdmins: await anzahlAktiverAdmins(),
}) })
if (schutz) return { fehler: schutz } if (schutz) return { fehler: schutz }
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers - // Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte. // sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
await db.user.update({ await db.user.update({
where: { id: ziel.id }, where: { id: ziel.id },
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } }, data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
}) })
await protokollieren({ await protokollieren({
aktion: 'ROLLE_GEAENDERT', aktion: 'ROLLE_GEAENDERT',
entitaet: 'User', entitaet: 'User',
entitaetId: ziel.id, entitaetId: ziel.id,
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`, bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
userId: admin.id, userId: admin.id,
userEmail: admin.email, userEmail: admin.email,
userName: admin.name, userName: admin.name,
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } }, aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
}) })
revalidatePath('/einstellungen/benutzer') revalidatePath('/einstellungen/benutzer')
return { return {
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`, erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
} }
} }
export async function benutzerAktivSetzen( export async function benutzerAktivSetzen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = benutzerAktivSchema.safeParse({ const gelesen = benutzerAktivSchema.safeParse({
userId: formular.get('userId'), userId: formular.get('userId'),
aktiv: formular.get('aktiv') === 'ja', aktiv: formular.get('aktiv') === 'ja',
}) })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' } if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer') const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } }) const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' } if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.id === admin.id && !gelesen.data.aktiv) { if (ziel.id === admin.id && !gelesen.data.aktiv) {
return { fehler: 'Du kannst dich nicht selbst abschalten.' } return { fehler: 'Du kannst dich nicht selbst abschalten.' }
} }
const schutz = letzterAdminGeschuetzt({ const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle, zielRolleVorher: ziel.rolle,
zielRolleNachher: ziel.rolle, zielRolleNachher: ziel.rolle,
zielBleibtAktiv: gelesen.data.aktiv, zielBleibtAktiv: gelesen.data.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(), anzahlAktiverAdmins: await anzahlAktiverAdmins(),
}) })
if (schutz) return { fehler: schutz } if (schutz) return { fehler: schutz }
await db.user.update({ await db.user.update({
where: { id: ziel.id }, where: { id: ziel.id },
data: { data: {
aktiv: gelesen.data.aktiv, aktiv: gelesen.data.aktiv,
// Wer abgeschaltet wird, ist sofort abgemeldet. // Wer abgeschaltet wird, ist sofort abgemeldet.
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 }, sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
}, },
}) })
await protokollieren({ await protokollieren({
aktion: 'GEAENDERT', aktion: 'GEAENDERT',
entitaet: 'User', entitaet: 'User',
entitaetId: ziel.id, entitaetId: ziel.id,
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`, bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
userId: admin.id, userId: admin.id,
userEmail: admin.email, userEmail: admin.email,
userName: admin.name, userName: admin.name,
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } }, aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
}) })
revalidatePath('/einstellungen/benutzer') revalidatePath('/einstellungen/benutzer')
return { return {
erfolg: gelesen.data.aktiv erfolg: gelesen.data.aktiv
? `${ziel.name} kann sich wieder anmelden.` ? `${ziel.name} kann sich wieder anmelden.`
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`, : `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
} }
} }
export async function sitzungenBeenden( export async function sitzungenBeenden(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') }) const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' } if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer') const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } }) const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' } if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
await db.user.update({ await db.user.update({
where: { id: ziel.id }, where: { id: ziel.id },
data: { sitzungsGeneration: { increment: 1 } }, data: { sitzungsGeneration: { increment: 1 } },
}) })
await protokollieren({ await protokollieren({
aktion: 'GEAENDERT', aktion: 'GEAENDERT',
entitaet: 'User', entitaet: 'User',
entitaetId: ziel.id, entitaetId: ziel.id,
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`, bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
userId: admin.id, userId: admin.id,
userEmail: admin.email, userEmail: admin.email,
userName: admin.name, userName: admin.name,
aenderung: { aenderung: {
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 }, sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
}, },
}) })
revalidatePath('/einstellungen/benutzer') revalidatePath('/einstellungen/benutzer')
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` } return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Freigaben je betreuter Person // Freigaben je betreuter Person
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export async function freigabenSpeichern( export async function freigabenSpeichern(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = freigabenSchema.safeParse({ const gelesen = freigabenSchema.safeParse({
userId: formular.get('userId'), userId: formular.get('userId'),
betreuteId: formular.get('betreuteId'), betreuteId: formular.get('betreuteId'),
rolle: formular.get('rolle'), rolle: formular.get('rolle'),
freigegebeneModule: formular.getAll('freigegebeneModule').map(String), freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
}) })
if (!gelesen.success) { if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) } return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
} }
const { userId, betreuteId, rolle } = gelesen.data const { userId, betreuteId, rolle } = gelesen.data
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben') const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) { if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' } return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
} }
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`) await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } }) const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' } if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[]) const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({ const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } }, where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true }, select: { rolle: true, freigegebeneModule: true },
}) })
const aenderung = freigabeAenderung( const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] }, vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: freigaben }, { rolle, freigegebeneModule: freigaben },
) )
await db.betreuungsZugang.upsert({ await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } }, where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben }, create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: freigaben }, update: { rolle, freigegebeneModule: freigaben },
}) })
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens // Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
// weiterbenutzen koennen. // weiterbenutzen koennen.
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) { if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } }) await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
} }
const beschreibung = [ const beschreibung = [
aenderung.rolleGeaendert aenderung.rolleGeaendert
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}` ? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
: null, : null,
aenderung.hinzugefuegt.length > 0 aenderung.hinzugefuegt.length > 0
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}` ? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null, : null,
aenderung.entfernt.length > 0 aenderung.entfernt.length > 0
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}` ? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null, : null,
] ]
.filter(Boolean) .filter(Boolean)
.join('; ') .join('; ')
await protokollieren({ await protokollieren({
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT', aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang', entitaet: 'BetreuungsZugang',
entitaetId: userId, entitaetId: userId,
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`, bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
betreuteId, betreuteId,
userId: benutzer.id, userId: benutzer.id,
userEmail: benutzer.email, userEmail: benutzer.email,
userName: benutzer.name, userName: benutzer.name,
aenderung: { aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher }, rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben }, freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
}, },
}) })
revalidatePath('/einstellungen/freigaben') revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
const warnung = const warnung =
aenderung.sensibelHinzugefuegt.length > 0 aenderung.sensibelHinzugefuegt.length > 0
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` + ? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.` `enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
: undefined : undefined
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung } return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
} }
export async function zugangEntziehen( export async function zugangEntziehen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const userId = String(formular.get('userId') ?? '') const userId = String(formular.get('userId') ?? '')
const betreuteId = String(formular.get('betreuteId') ?? '') const betreuteId = String(formular.get('betreuteId') ?? '')
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben') const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) { if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' } return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
} }
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`) await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
if (userId === benutzer.id) { if (userId === benutzer.id) {
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' } return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
} }
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } }) const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } }) await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } }) await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
await protokollieren({ await protokollieren({
aktion: 'FREIGABE_GEAENDERT', aktion: 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang', entitaet: 'BetreuungsZugang',
entitaetId: userId, entitaetId: userId,
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`, bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
betreuteId, betreuteId,
userId: benutzer.id, userId: benutzer.id,
userEmail: benutzer.email, userEmail: benutzer.email,
userName: benutzer.name, userName: benutzer.name,
}) })
revalidatePath('/einstellungen/freigaben') revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` } return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
} }
+293 -293
View File
@@ -1,293 +1,293 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client' import type { Modul } from '@prisma/client'
import { db, papierkorbGrenze } from '@/lib/db' import { db, papierkorbGrenze } from '@/lib/db'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung' import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { entfernen } from '@/lib/speicher' import { entfernen } from '@/lib/speicher'
import { papierkorbSchema } from '@/lib/validierung/freigaben' import { papierkorbSchema } from '@/lib/validierung/freigaben'
/** /**
* Papierkorb. * Papierkorb.
* *
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz * Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in * bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein * einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran. * echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
* *
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer * Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass * Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
* es ihn gab und wer ihn entfernt hat. * es ihn gab und wer ihn entfernt hat.
*/ */
export type Aktionszustand = { export type Aktionszustand = {
fehler?: string fehler?: string
erfolg?: string erfolg?: string
} }
/** /**
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine * Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und * dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
* daraus darf niemals ein beliebiger Datenbankzugriff werden. * daraus darf niemals ein beliebiger Datenbankzugriff werden.
*/ */
type Eintragsart = { type Eintragsart = {
bezeichnung: string bezeichnung: string
mehrzahl: string mehrzahl: string
modul: Modul modul: Modul
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]> laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
wiederherstellen: (id: string, betreuteId: string) => Promise<void> wiederherstellen: (id: string, betreuteId: string) => Promise<void>
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void> endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
} }
export type PapierkorbEintrag = { export type PapierkorbEintrag = {
id: string id: string
entitaet: string entitaet: string
bezeichnung: string bezeichnung: string
geloeschtAm: Date geloeschtAm: Date
} }
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */ /** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
function einfach<T extends { id: string; geloeschtAm: Date | null }>( function einfach<T extends { id: string; geloeschtAm: Date | null }>(
entitaet: string, entitaet: string,
bezeichnung: string, bezeichnung: string,
mehrzahl: string, mehrzahl: string,
modul: Modul, modul: Modul,
zugriff: { zugriff: {
findMany: (args: unknown) => Promise<T[]> findMany: (args: unknown) => Promise<T[]>
update: (args: unknown) => Promise<unknown> update: (args: unknown) => Promise<unknown>
delete: (args: unknown) => Promise<unknown> delete: (args: unknown) => Promise<unknown>
}, },
titelFeld: string, titelFeld: string,
): Eintragsart { ): Eintragsart {
return { return {
bezeichnung, bezeichnung,
mehrzahl, mehrzahl,
modul, modul,
laden: async (betreuteId, seit) => { laden: async (betreuteId, seit) => {
const treffer = await zugriff.findMany({ const treffer = await zugriff.findMany({
where: { betreuteId, geloeschtAm: { gte: seit } }, where: { betreuteId, geloeschtAm: { gte: seit } },
orderBy: { geloeschtAm: 'desc' }, orderBy: { geloeschtAm: 'desc' },
}) })
return treffer.map((datensatz) => ({ return treffer.map((datensatz) => ({
id: datensatz.id, id: datensatz.id,
entitaet, entitaet,
bezeichnung: bezeichnung:
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'), String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
geloeschtAm: datensatz.geloeschtAm!, geloeschtAm: datensatz.geloeschtAm!,
})) }))
}, },
wiederherstellen: async (id, betreuteId) => { wiederherstellen: async (id, betreuteId) => {
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } }) await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
}, },
endgueltigLoeschen: async (id, betreuteId) => { endgueltigLoeschen: async (id, betreuteId) => {
await zugriff.delete({ where: { id, betreuteId } }) await zugriff.delete({ where: { id, betreuteId } })
}, },
} }
} }
function eintragsarten(): Record<string, Eintragsart> { function eintragsarten(): Record<string, Eintragsart> {
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
const z = db as any const z = db as any
return { return {
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'), Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'), Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'), Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'), Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'), Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
Vermoegensposten: einfach( Vermoegensposten: einfach(
'Vermoegensposten', 'Vermoegensposten',
'Vermögensposten', 'Vermögensposten',
'Vermögensposten', 'Vermögensposten',
'FINANZEN', 'FINANZEN',
z.vermoegensposten, z.vermoegensposten,
'bezeichnung', 'bezeichnung',
), ),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'), Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'), Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach( Schreibvorlage: einfach(
'Schreibvorlage', 'Schreibvorlage',
'Vorlage', 'Vorlage',
'Vorlagen', 'Vorlagen',
'DOKUMENTE', 'DOKUMENTE',
z.schreibvorlage, z.schreibvorlage,
'name', 'name',
), ),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'), Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach( Kontaktprotokoll: einfach(
'Kontaktprotokoll', 'Kontaktprotokoll',
'Kontaktprotokoll', 'Kontaktprotokoll',
'Kontaktprotokolle', 'Kontaktprotokolle',
'KONTAKTE', 'KONTAKTE',
z.kontaktprotokoll, z.kontaktprotokoll,
'gespraechspartner', 'gespraechspartner',
), ),
Gesundheitsereignis: einfach( Gesundheitsereignis: einfach(
'Gesundheitsereignis', 'Gesundheitsereignis',
'Gesundheitsereignis', 'Gesundheitsereignis',
'Gesundheitsereignisse', 'Gesundheitsereignisse',
'GESUNDHEIT', 'GESUNDHEIT',
z.gesundheitsereignis, z.gesundheitsereignis,
'titel', 'titel',
), ),
Genehmigung: einfach( Genehmigung: einfach(
'Genehmigung', 'Genehmigung',
'Genehmigung', 'Genehmigung',
'Genehmigungen', 'Genehmigungen',
'VORGAENGE', 'VORGAENGE',
z.genehmigung, z.genehmigung,
'titel', 'titel',
), ),
SozialhilfeAntrag: einfach( SozialhilfeAntrag: einfach(
'SozialhilfeAntrag', 'SozialhilfeAntrag',
'Sozialhilfeantrag', 'Sozialhilfeantrag',
'Sozialhilfeanträge', 'Sozialhilfeanträge',
'SOZIALHILFE', 'SOZIALHILFE',
z.sozialhilfeAntrag, z.sozialhilfeAntrag,
'leistungsart', 'leistungsart',
), ),
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'), Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'), Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
} }
/* eslint-enable @typescript-eslint/no-explicit-any */ /* eslint-enable @typescript-eslint/no-explicit-any */
} }
/** Alles, was in den letzten 30 Tagen geloescht wurde. */ /** Alles, was in den letzten 30 Tagen geloescht wurde. */
export async function papierkorbLaden(betreuteId: string): Promise< export async function papierkorbLaden(betreuteId: string): Promise<
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
> { > {
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen') const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
const seit = papierkorbGrenze() const seit = papierkorbGrenze()
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = [] const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
for (const [art, beschreibung] of Object.entries(eintragsarten())) { for (const [art, beschreibung] of Object.entries(eintragsarten())) {
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist. // Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) { if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
continue continue
} }
const eintraege = await beschreibung.laden(betreuteId, seit) const eintraege = await beschreibung.laden(betreuteId, seit)
if (eintraege.length > 0) { if (eintraege.length > 0) {
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege }) gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
} }
} }
return gruppen return gruppen
} }
/** /**
* Haengt an diesem Datensatz eine Datei im Objektspeicher? * Haengt an diesem Datensatz eine Datei im Objektspeicher?
* *
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber * Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell * eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das * eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen. * andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/ */
async function objektSchluesselFinden( async function objektSchluesselFinden(
entitaet: string, entitaet: string,
entitaetId: string, entitaetId: string,
betreuteId: string, betreuteId: string,
): Promise<string | null> { ): Promise<string | null> {
if (entitaet === 'Dokument') { if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({ const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId }, where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true }, select: { objektSchluessel: true },
}) })
return dokument?.objektSchluessel ?? null return dokument?.objektSchluessel ?? null
} }
if (entitaet === 'Bericht') { if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({ const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId }, where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true }, select: { objektSchluessel: true },
}) })
return bericht?.objektSchluessel ?? null return bericht?.objektSchluessel ?? null
} }
return null return null
} }
export async function wiederherstellen( export async function wiederherstellen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({ const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'), betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'), entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'), entitaetId: formular.get('entitaetId'),
}) })
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' } if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet] const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' } if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben') const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' } if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId) await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({ await protokollieren({
aktion: 'WIEDERHERGESTELLT', aktion: 'WIEDERHERGESTELLT',
entitaet: gelesen.data.entitaet, entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId, entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`, bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
betreuteId: gelesen.data.betreuteId, betreuteId: gelesen.data.betreuteId,
userId: benutzer.id, userId: benutzer.id,
userEmail: benutzer.email, userEmail: benutzer.email,
userName: benutzer.name, userName: benutzer.name,
}) })
revalidatePath('/einstellungen/papierkorb') revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist wieder da.` } return { erfolg: `${art.bezeichnung} ist wieder da.` }
} }
/** /**
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im * Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung. * Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
*/ */
export async function endgueltigLoeschen( export async function endgueltigLoeschen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
): Promise<Aktionszustand> { ): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({ const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'), betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'), entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'), entitaetId: formular.get('entitaetId'),
}) })
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' } if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet] const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' } if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben') const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' } if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb') await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst // Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten, // entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten. // die ausdruecklich verschwinden sollten.
const schluessel = await objektSchluesselFinden( const schluessel = await objektSchluesselFinden(
gelesen.data.entitaet, gelesen.data.entitaet,
gelesen.data.entitaetId, gelesen.data.entitaetId,
gelesen.data.betreuteId, gelesen.data.betreuteId,
) )
if (schluessel) { if (schluessel) {
try { try {
await entfernen(schluessel) await entfernen(schluessel)
} catch (fehler) { } catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler) console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
} }
} }
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId) await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({ await protokollieren({
aktion: 'ENDGUELTIG_GELOESCHT', aktion: 'ENDGUELTIG_GELOESCHT',
entitaet: gelesen.data.entitaet, entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId, entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} endgültig gelöscht`, bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
betreuteId: gelesen.data.betreuteId, betreuteId: gelesen.data.betreuteId,
userId: benutzer.id, userId: benutzer.id,
userEmail: benutzer.email, userEmail: benutzer.email,
userName: benutzer.name, userName: benutzer.name,
}) })
revalidatePath('/einstellungen/papierkorb') revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` } return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
} }
+665 -665
View File
File diff suppressed because it is too large Load Diff
+607 -607
View File
File diff suppressed because it is too large Load Diff
+637 -637
View File
File diff suppressed because it is too large Load Diff
+343 -343
View File
@@ -1,343 +1,343 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { tag } from '@/lib/fristen/datum' import { tag } from '@/lib/fristen/datum'
import { import {
absendetagZu, absendetagZu,
kuendigungsampel, kuendigungsampel,
kuendigungsplan, kuendigungsplan,
KUENDIGUNG_VORWARNUNG_TAGE, KUENDIGUNG_VORWARNUNG_TAGE,
laufzeitAusVertrag, laufzeitAusVertrag,
POSTLAUF_PUFFER_TAGE, POSTLAUF_PUFFER_TAGE,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent' import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent'
import { import {
anfrageStatus, anfrageStatus,
juengsterWert, juengsterWert,
rueckkaufswertAnschreiben, rueckkaufswertAnschreiben,
wertHistorie, wertHistorie,
wiedervorlageAm, wiedervorlageAm,
} from '@/lib/vertraege/rueckkaufswert' } from '@/lib/vertraege/rueckkaufswert'
import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten' import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
const kopf = { const kopf = {
betreuerName: 'Florian Wilde', betreuerName: 'Florian Wilde',
betreuerStrasse: 'Musterweg 1', betreuerStrasse: 'Musterweg 1',
betreuerPlz: '95444', betreuerPlz: '95444',
betreuerOrt: 'Bayreuth', betreuerOrt: 'Bayreuth',
betreuteName: 'Erika Wilde', betreuteName: 'Erika Wilde',
betreuteGeburtsdatum: tag(1948, 5, 20), betreuteGeburtsdatum: tag(1948, 5, 20),
betreuungsgericht: 'Amtsgericht Bayreuth', betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 123/25', aktenzeichenGericht: 'XVII 123/25',
bestellungAm: tag(2025, 11, 3), bestellungAm: tag(2025, 11, 3),
} }
describe('Kündigungstermin', () => { describe('Kündigungstermin', () => {
it('rechnet den nächstmöglichen Termin über die Jahresgrenze', () => { it('rechnet den nächstmöglichen Termin über die Jahresgrenze', () => {
// Jahresvertrag seit dem 15.03.2020, drei Monate Kündigungsfrist. // Jahresvertrag seit dem 15.03.2020, drei Monate Kündigungsfrist.
// Für den Termin 15.03.2026 müsste der Zugang am 15.12.2025 vorliegen - // Für den Termin 15.03.2026 müsste der Zugang am 15.12.2025 vorliegen -
// der Absendetag ist Anfang Februar 2026 längst vorbei. // der Absendetag ist Anfang Februar 2026 längst vorbei.
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15), beginnAm: tag(2020, 3, 15),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.berechenbar).toBe(true) expect(plan.berechenbar).toBe(true)
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15)) expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 12, 15)) expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 12, 15))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 12, 12)) expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 12, 12))
expect(plan.verpasst).toBe(false) expect(plan.verpasst).toBe(false)
}) })
it('rechnet über die Monatsgrenze kalendarisch, nicht in Tagen', () => { it('rechnet über die Monatsgrenze kalendarisch, nicht in Tagen', () => {
// 31.05. minus drei Monate ist der 28.02. (2026 ist kein Schaltjahr), // 31.05. minus drei Monate ist der 28.02. (2026 ist kein Schaltjahr),
// nicht der 02.03. oder 03.03. // nicht der 02.03. oder 03.03.
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31), beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 1, 5), heute: tag(2026, 1, 5),
}) })
expect(plan.naechsterTermin).toEqual(tag(2026, 5, 31)) expect(plan.naechsterTermin).toEqual(tag(2026, 5, 31))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 2, 28)) expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 2, 28))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 2, 25)) expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 2, 25))
}) })
it('trifft im Schaltjahr den 29. Februar', () => { it('trifft im Schaltjahr den 29. Februar', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31), beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2024, 1, 5), heute: tag(2024, 1, 5),
}) })
expect(plan.zugangSpaetestensAm).toEqual(tag(2024, 2, 29)) expect(plan.zugangSpaetestensAm).toEqual(tag(2024, 2, 29))
}) })
it('springt bei mehrjähriger Laufzeit um die volle Laufzeit weiter', () => { it('springt bei mehrjähriger Laufzeit um die volle Laufzeit weiter', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1), beginnAm: tag(2018, 1, 1),
laufzeitJahre: 3, laufzeitJahre: 3,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
// Termine: 2021, 2024, 2027 - für 2027 muss bis 30.09.2026 zugehen. // Termine: 2021, 2024, 2027 - für 2027 muss bis 30.09.2026 zugehen.
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1)) expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 10, 1)) expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 10, 1))
}) })
it('rechnet den Postlauf zwischen Absenden und Zugang ein', () => { it('rechnet den Postlauf zwischen Absenden und Zugang ein', () => {
expect(absendetagZu(tag(2026, 6, 30), 3)).toEqual(tag(2026, 3, 27)) expect(absendetagZu(tag(2026, 6, 30), 3)).toEqual(tag(2026, 3, 27))
expect(POSTLAUF_PUFFER_TAGE).toBe(3) expect(POSTLAUF_PUFFER_TAGE).toBe(3)
}) })
it('warnt, wenn der Absendetag näher als 30 Tage rückt', () => { it('warnt, wenn der Absendetag näher als 30 Tage rückt', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30), beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 3, 20), heute: tag(2026, 3, 20),
}) })
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 3, 27)) expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 3, 27))
expect(plan.tageBisAbsendetag).toBe(7) expect(plan.tageBisAbsendetag).toBe(7)
expect(plan.vorwarnung).toBe(true) expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot') expect(kuendigungsampel(plan)?.variante).toBe('rot')
}) })
it('warnt genau an der Grenze von 30 Tagen', () => { it('warnt genau an der Grenze von 30 Tagen', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30), beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 25), heute: tag(2026, 2, 25),
}) })
expect(plan.tageBisAbsendetag).toBe(KUENDIGUNG_VORWARNUNG_TAGE) expect(plan.tageBisAbsendetag).toBe(KUENDIGUNG_VORWARNUNG_TAGE)
expect(plan.vorwarnung).toBe(true) expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('orange') expect(kuendigungsampel(plan)?.variante).toBe('orange')
}) })
it('warnt einen Tag vor der Grenze noch nicht', () => { it('warnt einen Tag vor der Grenze noch nicht', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30), beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 24), heute: tag(2026, 2, 24),
}) })
expect(plan.tageBisAbsendetag).toBe(31) expect(plan.tageBisAbsendetag).toBe(31)
expect(plan.vorwarnung).toBe(false) expect(plan.vorwarnung).toBe(false)
}) })
it('nimmt ohne hinterlegte Laufzeit ein Jahr an und sagt das dazu', () => { it('nimmt ohne hinterlegte Laufzeit ein Jahr an und sagt das dazu', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15), beginnAm: tag(2020, 3, 15),
laufzeitJahre: null, laufzeitJahre: null,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15)) expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.hinweise.some((h) => h.includes('keine Laufzeit'))).toBe(true) expect(plan.hinweise.some((h) => h.includes('keine Laufzeit'))).toBe(true)
}) })
it('meldet einen verpassten Termin, wenn sich der Vertrag nicht verlängert', () => { it('meldet einen verpassten Termin, wenn sich der Vertrag nicht verlängert', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2010, 1, 1), beginnAm: tag(2010, 1, 1),
endeAm: tag(2026, 3, 1), endeAm: tag(2026, 3, 1),
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
verlaengertSichAutomatisch: false, verlaengertSichAutomatisch: false,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.verpasst).toBe(true) expect(plan.verpasst).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot') expect(kuendigungsampel(plan)?.variante).toBe('rot')
}) })
it('rechnet gar nicht, wenn weder Beginn noch Ende bekannt sind', () => { it('rechnet gar nicht, wenn weder Beginn noch Ende bekannt sind', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: null, beginnAm: null,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.berechenbar).toBe(false) expect(plan.berechenbar).toBe(false)
expect(plan.naechsterTermin).toBeNull() expect(plan.naechsterTermin).toBeNull()
expect(kuendigungsampel(plan)).toBeNull() expect(kuendigungsampel(plan)).toBeNull()
}) })
it('weist auf die fehlende Kündigungsfrist hin, statt still mit null zu rechnen', () => { it('weist auf die fehlende Kündigungsfrist hin, statt still mit null zu rechnen', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15), beginnAm: tag(2020, 3, 15),
kuendigungsfristMonate: null, kuendigungsfristMonate: null,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.hinweise.some((h) => h.includes('keine Kündigungsfrist'))).toBe(true) expect(plan.hinweise.some((h) => h.includes('keine Kündigungsfrist'))).toBe(true)
}) })
}) })
describe('Rückkaufswert-Anforderung', () => { describe('Rückkaufswert-Anforderung', () => {
it('legt die Wiedervorlage vier Wochen nach der Anfrage', () => { it('legt die Wiedervorlage vier Wochen nach der Anfrage', () => {
expect(wiedervorlageAm(tag(2026, 3, 2))).toEqual(tag(2026, 3, 30)) expect(wiedervorlageAm(tag(2026, 3, 2))).toEqual(tag(2026, 3, 30))
}) })
it('fragt Rückkaufswert und beitragsfreie Versicherungssumme zusammen ab', () => { it('fragt Rückkaufswert und beitragsfreie Versicherungssumme zusammen ab', () => {
const brief = rueckkaufswertAnschreiben({ const brief = rueckkaufswertAnschreiben({
kopf, kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: 'Postfach 1\n80000 München' }, empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: 'Postfach 1\n80000 München' },
vertragsbezeichnung: 'Sterbegeldversicherung', vertragsbezeichnung: 'Sterbegeldversicherung',
vertragsnummer: 'LV-4711', vertragsnummer: 'LV-4711',
stichtag: tag(2026, 1, 1), stichtag: tag(2026, 1, 1),
heute: tag(2026, 3, 2), heute: tag(2026, 3, 2),
}) })
expect(brief).toContain('LV-4711') expect(brief).toContain('LV-4711')
expect(brief).toContain('Rückkaufswert') expect(brief).toContain('Rückkaufswert')
expect(brief).toContain('beitragsfreie Versicherungssumme') expect(brief).toContain('beitragsfreie Versicherungssumme')
expect(brief).toContain('Amtsgericht Bayreuth') expect(brief).toContain('Amtsgericht Bayreuth')
expect(brief).toContain('XVII 123/25') expect(brief).toContain('XVII 123/25')
expect(brief).toContain('01.01.2026') expect(brief).toContain('01.01.2026')
}) })
it('meldet die Erinnerung, sobald vier Wochen ohne Antwort vergangen sind', () => { it('meldet die Erinnerung, sobald vier Wochen ohne Antwort vergangen sind', () => {
expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 29)).erinnerungFaellig).toBe(false) expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 29)).erinnerungFaellig).toBe(false)
expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 30)).erinnerungFaellig).toBe(true) expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 30)).erinnerungFaellig).toBe(true)
expect(anfrageStatus(null, tag(2026, 3, 30)).offen).toBe(false) expect(anfrageStatus(null, tag(2026, 3, 30)).offen).toBe(false)
}) })
}) })
describe('Kündigungs-Assistent', () => { describe('Kündigungs-Assistent', () => {
const eingabe = { const eingabe = {
kopf, kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: null }, empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: null },
vertragsbezeichnung: 'Kapitallebensversicherung', vertragsbezeichnung: 'Kapitallebensversicherung',
vertragsnummer: 'LV-4711', vertragsnummer: 'LV-4711',
art: 'LEBENSVERSICHERUNG' as const, art: 'LEBENSVERSICHERUNG' as const,
laufzeit: { laufzeit: {
beginnAm: tag(2000, 4, 1), beginnAm: tag(2000, 4, 1),
laufzeitJahre: 1, laufzeitJahre: 1,
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
}, },
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
} }
it('erzeugt ein Schreiben mit Vertragsnummer und Bestellungsnachweis', () => { it('erzeugt ein Schreiben mit Vertragsnummer und Bestellungsnachweis', () => {
const paket = kuendigungsassistent(eingabe) const paket = kuendigungsassistent(eingabe)
expect(paket.schreiben).toContain('LV-4711') expect(paket.schreiben).toContain('LV-4711')
expect(paket.schreiben).toContain('kündige ich den oben genannten Vertrag') expect(paket.schreiben).toContain('kündige ich den oben genannten Vertrag')
expect(paket.schreiben).toContain('rechtlichen Betreuer') expect(paket.schreiben).toContain('rechtlichen Betreuer')
expect(paket.schreiben).toContain('hilfsweise zum nächstmöglichen Zeitpunkt') expect(paket.schreiben).toContain('hilfsweise zum nächstmöglichen Zeitpunkt')
}) })
it('stellt bei einer Lebensversicherung die Genehmigungsfrage, ohne sie zu beantworten', () => { it('stellt bei einer Lebensversicherung die Genehmigungsfrage, ohne sie zu beantworten', () => {
const paket = kuendigungsassistent(eingabe) const paket = kuendigungsassistent(eingabe)
expect(paket.genehmigung.stellen).toBe(true) expect(paket.genehmigung.stellen).toBe(true)
expect(paket.genehmigung.rechtsgrundlage).toBe('§ 1850 BGB') expect(paket.genehmigung.rechtsgrundlage).toBe('§ 1850 BGB')
expect(paket.genehmigung.frage).toMatch(/Rechtspfleger/) expect(paket.genehmigung.frage).toMatch(/Rechtspfleger/)
expect(paket.genehmigung.hinweis).toMatch(/entscheidet das ausdrücklich nicht/) expect(paket.genehmigung.hinweis).toMatch(/entscheidet das ausdrücklich nicht/)
}) })
it('stellt bei einer Hausratversicherung keine Genehmigungsfrage', () => { it('stellt bei einer Hausratversicherung keine Genehmigungsfrage', () => {
expect(genehmigungsfrage('HAUSRAT').stellen).toBe(false) expect(genehmigungsfrage('HAUSRAT').stellen).toBe(false)
expect(genehmigungsfrage('LEBENSVERSICHERUNG').stellen).toBe(true) expect(genehmigungsfrage('LEBENSVERSICHERUNG').stellen).toBe(true)
}) })
it('verlangt immer einen Versandnachweis', () => { it('verlangt immer einen Versandnachweis', () => {
const paket = kuendigungsassistent(eingabe) const paket = kuendigungsassistent(eingabe)
expect(paket.versandnachweis.verlangt).toBe(true) expect(paket.versandnachweis.verlangt).toBe(true)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Einschreiben/) expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Einschreiben/)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Fax/) expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Fax/)
}) })
it('erinnert vor der Kündigung an die Beitragsfreistellung als Alternative', () => { it('erinnert vor der Kündigung an die Beitragsfreistellung als Alternative', () => {
const paket = kuendigungsassistent(eingabe) const paket = kuendigungsassistent(eingabe)
expect(paket.warnungen.some((w) => w.includes('beitragsfreie Versicherungssumme'))).toBe(true) expect(paket.warnungen.some((w) => w.includes('beitragsfreie Versicherungssumme'))).toBe(true)
}) })
it('warnt bei einer Bestattungsvorsorge vor der vorschnellen Kündigung', () => { it('warnt bei einer Bestattungsvorsorge vor der vorschnellen Kündigung', () => {
const paket = kuendigungsassistent({ ...eingabe, art: 'BESTATTUNGSVORSORGE' }) const paket = kuendigungsassistent({ ...eingabe, art: 'BESTATTUNGSVORSORGE' })
expect(paket.warnungen.some((w) => w.includes('§ 90 Abs. 3 SGB XII'))).toBe(true) expect(paket.warnungen.some((w) => w.includes('§ 90 Abs. 3 SGB XII'))).toBe(true)
}) })
it('warnt, wenn die Vertragsnummer fehlt', () => { it('warnt, wenn die Vertragsnummer fehlt', () => {
const paket = kuendigungsassistent({ ...eingabe, vertragsnummer: null }) const paket = kuendigungsassistent({ ...eingabe, vertragsnummer: null })
expect(paket.warnungen.some((w) => w.includes('Vertragsnummer fehlt'))).toBe(true) expect(paket.warnungen.some((w) => w.includes('Vertragsnummer fehlt'))).toBe(true)
}) })
}) })
describe('Beiträge und Vertragsarten', () => { describe('Beiträge und Vertragsarten', () => {
it('rechnet den Jahresbeitrag aus Beitrag und Rhythmus', () => { it('rechnet den Jahresbeitrag aus Beitrag und Rhythmus', () => {
expect(jahresbeitragCent(2_500n, 'MONATLICH')).toBe(30_000n) expect(jahresbeitragCent(2_500n, 'MONATLICH')).toBe(30_000n)
expect(jahresbeitragCent(2_500n, 'VIERTELJAEHRLICH')).toBe(10_000n) expect(jahresbeitragCent(2_500n, 'VIERTELJAEHRLICH')).toBe(10_000n)
expect(jahresbeitragCent(2_500n, 'JAEHRLICH')).toBe(2_500n) expect(jahresbeitragCent(2_500n, 'JAEHRLICH')).toBe(2_500n)
expect(jahresbeitragCent(2_500n, 'EINMALIG')).toBeNull() expect(jahresbeitragCent(2_500n, 'EINMALIG')).toBeNull()
expect(jahresbeitragCent(null, 'MONATLICH')).toBeNull() expect(jahresbeitragCent(null, 'MONATLICH')).toBeNull()
}) })
it('kennt die Verträge, bei denen ein Rückkaufswert überhaupt anfällt', () => { it('kennt die Verträge, bei denen ein Rückkaufswert überhaupt anfällt', () => {
expect(kannRueckkaufswertHaben('LEBENSVERSICHERUNG')).toBe(true) expect(kannRueckkaufswertHaben('LEBENSVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STERBEGELDVERSICHERUNG')).toBe(true) expect(kannRueckkaufswertHaben('STERBEGELDVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STROM')).toBe(false) expect(kannRueckkaufswertHaben('STROM')).toBe(false)
}) })
}) })
describe('Vertragsdauer aus Beginn und Ende', () => { describe('Vertragsdauer aus Beginn und Ende', () => {
it('leitet die Laufzeit aus einem festen Vertragsende ab', () => { it('leitet die Laufzeit aus einem festen Vertragsende ab', () => {
expect(laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1))).toBe(3) expect(laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1))).toBe(3)
expect(laufzeitAusVertrag(tag(2020, 3, 15), tag(2021, 3, 15))).toBe(1) expect(laufzeitAusVertrag(tag(2020, 3, 15), tag(2021, 3, 15))).toBe(1)
}) })
it('gibt null zurück, wenn ein Datum fehlt oder die Spanne unter einem Jahr liegt', () => { it('gibt null zurück, wenn ein Datum fehlt oder die Spanne unter einem Jahr liegt', () => {
expect(laufzeitAusVertrag(tag(2020, 1, 1), null)).toBeNull() expect(laufzeitAusVertrag(tag(2020, 1, 1), null)).toBeNull()
expect(laufzeitAusVertrag(null, tag(2021, 1, 1))).toBeNull() expect(laufzeitAusVertrag(null, tag(2021, 1, 1))).toBeNull()
expect(laufzeitAusVertrag(tag(2020, 1, 1), tag(2020, 7, 1))).toBeNull() expect(laufzeitAusVertrag(tag(2020, 1, 1), tag(2020, 7, 1))).toBeNull()
}) })
it('lässt einen dreijährigen Vertrag um drei Jahre weiterspringen', () => { it('lässt einen dreijährigen Vertrag um drei Jahre weiterspringen', () => {
const plan = kuendigungsplan({ const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1), beginnAm: tag(2018, 1, 1),
endeAm: tag(2021, 1, 1), endeAm: tag(2021, 1, 1),
laufzeitJahre: laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1)), laufzeitJahre: laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1)),
kuendigungsfristMonate: 3, kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1), heute: tag(2026, 2, 1),
}) })
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1)) expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
}) })
}) })
describe('Rückkaufswert-Historie am Vertrag', () => { describe('Rückkaufswert-Historie am Vertrag', () => {
const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent }) const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent })
it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => { it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => {
// Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim // Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim
// ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine. // ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine.
const historie = wertHistorie([ const historie = wertHistorie([
{ wertstaende: [wert(2024, 4000)] }, { wertstaende: [wert(2024, 4000)] },
{ wertstaende: [wert(2026, 5200), wert(2025, 4600)] }, { wertstaende: [wert(2026, 5200), wert(2025, 4600)] },
]) ])
expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000]) expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000])
}) })
it('nennt den zuletzt mitgeteilten Wert', () => { it('nennt den zuletzt mitgeteilten Wert', () => {
expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe( expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe(
5200, 5200,
) )
}) })
it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => { it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => {
expect(juengsterWert([{ wertstaende: [] }])).toBeNull() expect(juengsterWert([{ wertstaende: [] }])).toBeNull()
expect(juengsterWert([])).toBeNull() expect(juengsterWert([])).toBeNull()
}) })
}) })