Compare commits

...
19 Commits
Author SHA1 Message Date
florian.wilde ece995bb6a Merge #37: Update brach ohne Meldung ab
Abgeschnittene Leitungen unter pipefail beendeten update.sh stumm und
haetten die Selbstpruefung der Sicherung falsch negativ gemacht. Behoben,
mit ERR-Falle und Test gegen das Muster.
2026-08-23 22:36:53 +00:00
Florian Wilde 107406e3c0 Update brach ohne Meldung ab
Beim Sprung von 1.5.0 auf 1.10.1 endete scripts/update.sh mitten in der
Ausgabe. Kein Fehler, keine Zeile, einfach zurueck zur
Eingabeaufforderung.

Ursache war eine abgeschnittene Leitung:

  git log --oneline "HEAD..$ZIEL" | head -15 | sed ...

head schliesst die Leitung nach 15 Zeilen, git log schreibt weiter und
bekommt SIGPIPE (141). Mit "set -o pipefail" gilt die ganze Kette als
gescheitert, "set -e" beendet das Skript - stumm.

Zeitabhaengig, deshalb monatelang unauffaellig: Solange git log fertig
wurde, bevor head zumachte, ging es gut. Die Schwelle liegt exakt bei
15 Commits, und v1.5.0..v1.10.0 waren die ersten 16.

Behoben, indem der Erzeuger selbst begrenzt (git log -15) statt die
Leitung zu kappen. Dieselbe Behandlung fuer die beiden Tag-Listen.

Dieselbe Falle steckte in der Selbstpruefung von sicherung.sh:

  openssl enc -d ... | head -c 5 | grep -q "PGDMP"

Beide Leser schliessen frueh. Bei kleiner Datenbank steht alles im
Puffer und es faellt nicht auf; mit wachsender Datenbank kippt es - und
dann meldet das Skript eine INTAKTE Sicherung als kaputt und verweigert
das Update. Jetzt liest dd eine feste Menge und endet regulaer.

Damit so etwas nicht wieder stumm passiert:
- Eine ERR-Falle nennt Zeilennummer und Exitcode und erklaert bei 141,
  dass es SIGPIPE ist und ein Fehler im Skript, nicht beim Benutzer.
- Ein Test verbietet "| head" in Skripten mit pipefail und nennt die
  Zeile. Gegenprobe gemacht: Er schlaegt an.
- Bei mehr als 15 Commits steht "... und N weitere" darunter, statt sie
  wortlos wegzulassen.

Nachgestellt: der Ablauf von 1.5.0 auf 1.10.1 laeuft durch.
2026-08-24 00:36:08 +02:00
florian.wilde 9ea78a4d29 Merge #36: Zeilenenden im Repository auf LF
Behebt Issue #26: .gitattributes legt LF fest, 29 Dateien normalisiert.
Nachgewiesen nur Zeilenenden, kein Zeichen Inhalt geaendert.

Closes #26
2026-08-23 22:28:47 +00:00
Florian Wilde 1ed19eb76e Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
2026-08-23 23:37:02 +02:00
Florian Wilde 6a0db51e3b Zeilenenden im Repository auf LF festlegen
Ohne .gitattributes liegen Dateien gemischt vor: 29 von 500 mit CRLF,
der Rest mit LF. Jedes zeilenweise arbeitende Werkzeug schreibt eine
CRLF-Datei dann mit LF zurueck, und der Diff zeigt statt drei
geaenderten Zeilen die ganze Datei - bei Issue #7 waren es 578 statt 8,
kurz vor dem Push bemerkt.

Dieser Commit legt nur die Regel fest. Die Normalisierung der 29 Dateien
kommt getrennt, damit die Historie lesbar bleibt.

Refs #26
2026-08-23 23:34:38 +02:00
florian.wilde 851724774a Merge #35: Doppelt gerenderte Listen dokumentieren
Behebt Issue #28: Kommentar an der Dokumentenliste, Muster und drei
tragfaehige Wege in e2e/hilfen.ts, Begruendung warum die Verdopplung
bleibt.

Closes #28
2026-08-23 21:29:15 +00:00
Florian Wilde 3772e360e1 Doppelt gerenderte Listen dokumentieren
Die Dokumentenliste rendert jeden Eintrag zweimal: als Karte fuers Handy,
als Tabellenzeile ab Tablet. Eine Fassung ist per Breakpoint versteckt,
steht aber im DOM. Beim Testen trifft getByText(...).first() deshalb die
unsichtbare Kopie, und die Pruefung scheitert mit "element is not
visible" an einer Stelle, die augenscheinlich in Ordnung ist. Das hat
mehrfach Zeit gekostet, weil man den Grund nicht sieht.

Nachgemessen an /dokumente mit acht Dokumenten: ein Titel liefert 16
Treffer, davon 8 sichtbar - und first() ist unsichtbar.

- Kommentar an der Komponente, mit dem Verweis auf die Hilfsdatei.
- In e2e/hilfen.ts das Muster beschrieben und drei Wege genannt, die
  tragen: innerText fuer "steht das da", visible=true zum Klicken, die
  Zusammenfassungszeile fuer Anzahlen. Alle drei nachgeprueft, bevor sie
  aufgeschrieben wurden - eine Anleitung mit kaputtem Rezept waere
  schlimmer als keine.

Zur Frage aus dem Issue, ob sich die Verdopplung vermeiden laesst: nein,
nicht ohne Verlust. Eine Tabelle, die zu Karten umbricht, verlangt
display:block auf Zeilen und Zellen - damit ist es keine Tabelle mehr,
Vorlesesoftware verliert den Zusammenhang von Spaltenkopf und Wert, und
genau der macht eine Liste mit acht Spalten erst benutzbar. Die
Begruendung steht jetzt am Code, statt die Frage offen zu lassen.

Betroffen ist uebrigens nur diese eine Stelle, nicht mehrere:
kalender-monat.tsx und das Stammdatenformular nutzen Breakpoints, ohne
Inhalt doppelt zu rendern.

Refs #28
2026-08-23 23:28:22 +02:00
florian.wilde d75eb185d5 Merge #34: Verweise auf fremde Akten an einer Stelle pruefen
Behebt Issue #23: Durchgang durch alle Server Actions, eine Luecke
geschlossen (buchung.vorgangId), die Pruefung in lib/auth/verweise.ts
zusammengefasst.

Closes #23
2026-08-23 21:21:40 +00:00
Florian Wilde 91a9aca0d8 Verweise auf fremde Akten an einer Stelle pruefen
Wenn ein Formular einen Fremdschluessel mitschickt, kommt der aus einer
Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen -
eine veraenderte Anfrage kann jede beliebige Id enthalten.

Alle 22 Dateien unter src/server/actions/ durchgesehen. Das Ergebnis ist
besser als befuerchtet: Bis auf eine Stelle war jeder Verweis geprueft.

  buchung.vorgangId ging ungeprueft durch. Konto, Beleg und Vertrag
  wurden geprueft, der Vorgang nicht.

Die Pruefung liegt jetzt in lib/auth/verweise.ts statt verstreut in den
Aktionen. Die acht erlaubten Ziele sind eine ausgeschriebene Aufzaehlung
und kein dynamischer Tabellenzugriff: So sieht der Typpruefer jede
Abfrage, und welche Tabellen erreichbar sind, ist eine Entscheidung und
keine Nebenwirkung. Mehrere Verweise laufen nebeneinander - bei vier
Verweisen eine Wartezeit statt vier.

Umgestellt: die vier Einzelpruefungen der Buchung, die drei des
Vermoegenspostens, die eigene Hilfsfunktion fuer den Ansprechpartner.
Nicht angefasst wurden die Stellen, die ihre eigene, passendere Form
haben - etwa das Kontaktprotokoll, das beide Verweise parallel laedt und
feldgenaue Meldungen zurueckgibt.

Zum Test: Der erste Entwurf rief die Server Action per fetch nach. Der
waere auch ohne jede Pruefung gruen gewesen, weil Next einen Aufruf ohne
gueltige Action-Id ohnehin ablehnt - er haette nichts bewiesen. Der
jetzige schiebt dem echten Auswahlfeld einen Eintrag mit fremder Id
unter und schickt das Formular normal ab. Gegenprobe mit ausgebauter
Pruefung: faellt um, wie er soll.

Refs #23
2026-08-23 23:20:26 +02:00
florian.wilde 91592cbe88 Merge #33: Ein Jahr Kontobewegungen im Beispielfall
Behebt Issue #22: 107 Buchungen ueber zwoelf Monate im Demofall, mit
gerechnetem Kontostand. Dazu behoben: gGmbH fehlte in der Rechtsformliste
beim Zusammenfassen wiederkehrender Zahlungen.

Closes #22
2026-08-23 19:36:49 +00:00
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00
florian.wilde b6b579a04d Merge #32: Absenderanschrift in jedem Schreiben
Behebt Issue #21: Strasse, PLZ und Ort unter "Mein Konto", von dort in
den Absenderblock jedes Schreibens und als Ort in die Datumszeile. Was
fehlt, bleibt weg.

Closes #21
2026-08-23 18:30:23 +00:00
Florian Wilde 848aaf1b9f Absenderanschrift in jedem Schreiben
Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
2026-08-23 20:29:17 +02:00
florian.wilde 5022a97a6f Merge #31: Datenbankbefehle benennen, welcher Rueckfragen stellt
Behebt Issue #27: Uebersicht der sieben Datenbankbefehle in der README,
db:migrate als einziger interaktiver benannt, Ersteinrichtung auf
npm run setup vereinheitlicht.

Closes #27
2026-08-23 17:36:06 +00:00
Florian Wilde 110b11bef8 Datenbankbefehle benennen, welcher Rueckfragen stellt
"npm run db:migrate" ist "prisma migrate dev" und damit interaktiv: Es
fragt nach einem Namen und bietet an, die Datenbank zurueckzusetzen. In
einem Skript, in der CI oder aus einem Agenten heraus bleibt es haengen.
Aus dem Namen ging das nicht hervor, also lief man einmal hinein.

- Neuer Abschnitt in der README mit allen sieben Datenbankbefehlen,
  wofuer sie da sind und welcher Rueckfragen stellt. Es ist genau einer.
- Der uebliche Weg fuer eine Schemaaenderung steht als Dreizeiler
  daneben - ueber den Wrapper aus #24, nicht ueber migrate dev.
- Die Ersteinrichtung in der README rief "npx prisma migrate dev --name
  fundament" auf, obwohl es dafuer "npm run setup" gibt. Das war der
  Ausgangspunkt der Verwirrung; jetzt steht dort der eine Befehl.
- Der Hinweis unter "Betriebswissen", die SQL-Skripte seien von Hand
  anzustossen, galt nur fuer die Ersteinrichtung. Beim Aktualisieren
  erledigt update.sh das mit - jetzt steht das dabei.

"db:migrate" bleibt bestehen. Der interaktive Ablauf hat seinen Platz,
etwa um eine verkorkste Entwicklungsdatenbank zurueckzusetzen; er ist
nur nicht mehr der Standardweg.

Refs #27
2026-08-23 19:35:20 +02:00
florian.wilde 4aed9824d1 Merge #30: Migrationskommentare zeigen auf die richtige Datei
Behebt Issue #25: prisma/sql/suche.sql gibt es nicht, richtig ist
volltextsuche.sql. Dazu ein Verweis auf npm run db:migration aus #24.

Closes #25
2026-08-23 17:31:35 +00:00
Florian Wilde e19300b33d Migrationskommentare zeigen auf die richtige Datei
Fuenf Migrationen verwiesen fuer die Herkunft der Trigramm-Indizes auf
prisma/sql/suche.sql. Diese Datei gibt es nicht; sie heisst
volltextsuche.sql. Der Hinweis steht genau dort, wo ihn jemand liest,
der die naechste Migration erzeugt - ein falscher Pfad schickt ihn ins
Leere.

Dazu ein Verweis auf das Werkzeug aus #24: "npm run db:migration" nimmt
die Zeilen inzwischen selbst heraus, die Kommentare rieten aber
weiterhin nur zum Nachsehen.

Geprueft, bevor die Dateien angefasst wurden: Prisma speichert zwar eine
Pruefsumme je Migration, "migrate deploy" verifiziert sie fuer bereits
angewendete Migrationen aber nicht. Nachgestellt mit genau der Lage, die
auch auf dem Server entsteht - Pruefsumme in der Datenbank weicht ab,
"migrate status" und "migrate deploy" bleiben zufrieden.

Refs #25
2026-08-23 19:30:46 +02:00
florian.wilde 41e33ad85f Merge #29: Migrations-Wrapper gegen das Loeschen der Trigramm-Indizes
Behebt Issue #24: npm run db:migration erzeugt Migrationen ohne die
bekannten DROP-INDEX-Zeilen und prueft nach dem Anwenden, dass die
Indizes stehen.

Closes #24
2026-08-23 17:25:52 +00:00
Florian Wilde 3153e6179d Migrationen erzeugen, ohne die Suche kaputtzumachen
"prisma migrate diff" kennt die Trigramm-Indizes aus
prisma/sql/volltextsuche.sql nicht und schreibt deshalb in jede
Migration ein DROP INDEX dafuer. Fuenfmal mussten die Zeilen von Hand
raus. Auf dem Server waere es folgenlos geblieben - update.sh legt die
Indizes hinterher neu an -, in der Entwicklung dagegen waere die Suche
still langsam geworden.

  npm run db:migration -- <name> [--anwenden]
  npm run db:pruefen

Aufbau:
- Die Liste der geschuetzten Indizes steht an einer Stelle. Wer in
  prisma/sql/ einen hinzufuegt, traegt ihn dort ein.
- dropZeilenEntfernen() ist bewusst eng: Nur eine DROP-Anweisung, die
  einen geschuetzten Index nennt, faellt weg. Ein DROP INDEX auf einen
  Prisma-eigenen Index bleibt stehen - der soll ja weg. Ein Kommentar,
  der den Namen bloss erwaehnt, ebenfalls.
- Der Kopf der erzeugten Datei sagt, was entfernt wurde und warum.
- Nach --anwenden wird gegen die Datenbank geprueft, ob die Indizes
  wirklich noch stehen. Das ist der Unterschied zwischen "sollte
  gutgegangen sein" und "nachgesehen".

Beim Probelauf gefunden und behoben: Stimmen Schema und Datenbank
ueberein, besteht der ganze Diff aus den beiden bekannten DROP-Zeilen.
Das Skript legte dafuer eine leere Migration an - und eine leere
Migration in der Historie ist schlimmer als keine, weil sie als
angewendet gilt und niemand mehr weiss, wofuer sie gut war. Jetzt bricht
es vorher ab, ohne einen Ordner zu hinterlassen.

Die Typen liegen als .d.mts daneben: Das Skript bleibt .mjs und laeuft
ohne Bauschritt, die Tests laufen trotzdem nicht gegen "any".

Geprueft: zwoelf Einheitstests, dazu von Hand der Lauf mit echter
Schemaaenderung und der Lauf ohne.

Refs #24
2026-08-23 19:25:09 +02:00
64 changed files with 9463 additions and 7581 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
+139 -1
View File
@@ -26,6 +26,141 @@ Wiederherstellung*.
_Noch nichts._
## [1.10.2] – 2026-08-24
### Behoben
- **Das Update brach ohne Meldung ab.** Beim Sprung von 1.5.0 auf 1.10.1
endete `scripts/update.sh` mitten in der Ausgabe — kein Fehler, keine
Zeile, einfach zurück zur Eingabeaufforderung. Ursache war eine
abgeschnittene Leitung:
```
git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
```
`head` schließt die Leitung nach 15 Zeilen, `git log` schreibt weiter
und bekommt SIGPIPE. Weil das Skript mit `set -o pipefail` und
`set -e` läuft, gilt die ganze Kette als gescheitert und das Skript
beendet sich — stumm.
Das ist zeitabhängig und deshalb monatelang nicht aufgefallen: Solange
`git log` fertig war, bevor `head` zumachte, ging alles gut. Genau bei
1.10.0 lagen zum ersten Mal **mehr als 15 Commits** dazwischen.
- **Dieselbe Falle in der Selbstprüfung der Sicherung.** `sicherung.sh`
prüfte mit `openssl … | head -c 5 | grep -q "PGDMP"`, ob sich die
frische Sicherung wieder entschlüsseln lässt. Bei kleiner Datenbank
ging das gut, weil alles im Puffer stand. Mit wachsender Datenbank
hätte es gekippt — und dann hätte das Skript eine **völlig intakte
Sicherung als kaputt gemeldet** und das Update verweigert.
### Geändert
- **Ein stummer Abbruch ist nicht mehr möglich.** `update.sh` meldet
jetzt bei jedem unerwarteten Ende die Zeilennummer und den Exitcode,
und erklärt bei 141 ausdrücklich, dass es sich um SIGPIPE handelt und
um einen Fehler im Skript, nicht um einen Bedienfehler.
- Stehen mehr als 15 Commits an, steht jetzt „… und N weitere" darunter,
statt sie wortlos wegzulassen.
### Geprüft
- Ein Test verbietet das Muster künftig in allen Betriebsskripten:
Wer wieder `| head` in eine Leitung schreibt, bekommt die Zeilennummer
genannt. Gegenprobe gemacht — der Test schlägt an.
- Der Ablauf von 1.5.0 auf 1.10.1 einmal nachgestellt: läuft durch.
## [1.10.1] – 2026-08-23
### Geändert
- **Der Beispielfall hat jetzt ein Jahr Kontobewegungen**
([#22](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/22)).
Bisher brachte er Konten und Kontostände mit, aber keine einzige
Buchung — Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer. Wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.
Neu sind 107 Buchungen über zwölf Monate: Rente, Betriebsrente und
Pflegekasse als Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag,
Telefon und Kontoführung als monatliche Ausgaben, dazu Rundfunkbeitrag
und Grundsteuer vierteljährlich sowie eine Handvoll Einzelposten.
Das betrifft nur neue Installationen mit `SEED_DEMO=true` — an einem
bestehenden Bestand ändert sich nichts.
### Behoben
- **„gGmbH" wurde nicht als Rechtsform erkannt.** Beim Zusammenfassen
wiederkehrender Zahlungen galten „Seniorenzentrum St. Katharina" und
„Seniorenzentrum St. Katharina gGmbH" als zwei verschiedene Empfänger —
und damit zerfiel die Gruppe genau dort, wo sie am meisten zählt: Heime,
Sozialdienste und Träger sind fast durchweg gGmbH.
### Was der Beispielfall jetzt zeigt
- Das Heimentgelt steht in **drei Schreibweisen** auf dem Auszug und wird
trotzdem als eine monatliche Zahlung von 2.489 € geführt.
- Genau **eine** Buchung hat weder Quittung noch Begründung — damit die
Belegpflicht etwas zu zeigen hat, aber die Liste nicht rot leuchtet.
- Die Sterbegeld-Lastschriften sind bewusst **nicht** zugeordnet: So lässt
sich der Handgriff „zwölf Zahlungen als belegt zuordnen" ausprobieren.
Der Beitrag steigt im Mai von 18,40 € auf 19,80 €, damit auch die
Abweichungsprüfung sichtbar wird.
- Der Kontostand zum 31.07.2026 wird aus dem Anfangsstand und den
Buchungen **gerechnet**, nicht behauptet. Ein Beispielfall, der sich
selbst widerspricht, wäre schlimmer als einer ohne Buchungen.
### Geprüft
- Ein Einheitstest für die gGmbH-Erkennung.
- Vier End-to-End-Tests am Beispielfall: Umfang und Zeitraum der
Buchungen, die Übereinstimmung von Kontostand und Buchungssumme, die
erkannten Zahlungsgruppen und das Kassenbuch.
## [1.10.0] – 2026-08-23
### Hinzugefügt
- **Deine Anschrift steht jetzt in jedem Schreiben**
([#21](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/21)).
Bisher ging jeder Brief ohne Rücksendeadresse hinaus — im
Absenderblock stand nur dein Name, und in der Datumszeile fehlte der
Ort. Bei einer Versicherung oder Behörde wirkt das unseriös und macht
es der Gegenstelle unnötig schwer zu antworten.
Unter **Mein Konto → Meine Anschrift** einmal Straße, PLZ und Ort
hinterlegen. Ab da stehen sie im Absenderblock jedes erzeugten
Schreibens, und die Datumszeile lautet „Bayreuth, den 23.08.2026".
Betroffen ist alles: Rückkaufswert-Anfrage, Serienabfrage, Kündigung,
Sachstandsanfrage und die eigenen Vorlagen.
- **Zwei neue Platzhalter** für eigene Vorlagen: `{{betreuer_anschrift}}`
und `{{betreuer_ort}}`.
### Eine Zusage
**Was fehlt, bleibt weg.** Jede der drei Angaben ist einzeln freiwillig.
Hinterlegst du nur den Ort, bekommst du die Datumszeile mit Ort und einen
Absenderblock, der weiterhin nur aus deinem Namen besteht — keine leeren
Zeilen, kein Komma ohne Ort. Eine halb ausgefüllte Adresse sieht nach
Fehler aus, ein bloßer Name nach Absicht.
### Datenbank
Eine Migration: drei Felder am Benutzer. Die Anschrift hängt an dir und
nicht an der betreuten Person — sie gilt für alle Akten.
### Geprüft
- Sieben Einheitstests für den Absenderblock, darunter jede
Teilbefüllung und die Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet.
- Vier End-to-End-Tests: der Brief ohne hinterlegte Anschrift, das
Speichern unter *Mein Konto*, der Brief danach und die Gegenprobe am
Rückkaufswert-Schreiben.
## [1.9.0] – 2026-08-23
### Hinzugefügt
@@ -1036,7 +1171,10 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.2...HEAD
[1.10.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.1...v1.10.2
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
[1.10.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...v1.10.0
[1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0
[1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0
[1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
+35 -4
View File
@@ -97,11 +97,12 @@ DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit
**3. Datenbank einrichten:**
```bash
npx prisma migrate dev --name fundament
npm run db:sql # Volltextsuche und Audit-Schutz
npm run db:seed
npm run setup # Migrationen, Volltextsuche, Audit-Schutz, Beispieldaten
```
Das ist die Kurzform für `db:deploy`, `db:sql` und `db:seed` — die drei
Schritte laufen ohne Rückfrage durch.
**4. Starten:**
```bash
@@ -513,6 +514,35 @@ npm run protokoll # Bauprotokoll aus der Git-Historie
npm run version -- patch # Version anheben, Changelog vorbereiten
```
### Die Datenbankbefehle
Welcher Befehl wofür — und welcher am Terminal Rückfragen stellt. Nur
`db:migrate` ist interaktiv; alle anderen laufen in Skripten, in der CI
und im Container durch.
| Befehl | Wofür | Rückfragen? |
|---|---|---|
| `npm run setup` | Ersteinrichtung: Migrationen, SQL-Skripte, Beispieldaten | nein |
| `npm run db:migration -- <name>` | Schemaänderung während der Entwicklung — erzeugt die Migration und nimmt die `DROP INDEX`-Zeilen zur Volltextsuche heraus | nein |
| `npm run db:deploy` | Vorhandene Migrationen anwenden (Server, CI, Container) | nein |
| `npm run db:migrate` | `prisma migrate dev` — fragt nach dem Namen und bietet an, die Datenbank zurückzusetzen | **ja** |
| `npm run db:sql` | Volltextsuche und Audit-Schutz; läuft nicht mit den Migrationen mit | nein |
| `npm run db:pruefen` | Nachsehen, ob die Trigramm-Indizes noch stehen | nein |
| `npm run db:seed` | Beispieldaten einspielen | nein |
Der übliche Weg für eine Schemaänderung:
```bash
# Schema in prisma/schema.prisma ändern, dann
npm run db:migration -- ansprechpartner # erzeugen und lesen
npm run db:deploy # anwenden
npm run db:pruefen # Indizes nachsehen
```
`db:migrate` braucht man dafür nicht mehr. Es bleibt für die Fälle, in
denen man wirklich den interaktiven Prisma-Ablauf will — etwa um eine
verkorkste Entwicklungsdatenbank zurückzusetzen.
Konventionen — verbindlich für alle Beiträge:
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
Schnittstellen zwischen den Modulen:
@@ -545,7 +575,8 @@ Produktion.
über den Abschnitt `environment:`.
- Beim ersten Start eines Containers laufen die Migrationen automatisch
mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind
**einmalig von Hand** anzustoßen, siehe oben.
**einmalig von Hand** anzustoßen, siehe oben. Beim Aktualisieren
erledigt das `scripts/update.sh` mit — beide Skripte sind idempotent.
### Technische Grundlage
+7 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.9.0
git checkout v1.10.2
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.9.0
git describe --tags # v1.10.2
```
---
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.9.0","commit":"..."}
# {"status":"bereit","version":"1.10.2","commit":"..."}
```
Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash
curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.9.0",...}
# {"status":"bereit","version":"1.10.2",...}
```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.9.0 · <commit>`.
- Unten links in der Seitenleiste steht `Version 1.10.2 · <commit>`.
---
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung |
|---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.9.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh v1.10.2` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main
# 3. Umschalten
git checkout v1.9.0
git checkout v1.10.2
# 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.9.0** ([CHANGELOG](../CHANGELOG.md))
Stand: 23.08.2026 · Aktuelle Fassung: **1.10.2** ([CHANGELOG](../CHANGELOG.md))
---
+187 -187
View File
@@ -1,187 +1,187 @@
# Schema-Wünsche aus Etappe 3 „Papier“
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
funktioniert, wäre aber sauberer, wenn es im Modell stünde.
Reihenfolge: was am meisten weh tut, steht oben.
---
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
Prüfung in `dokumentVerknuepfen`).
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
Seed oder ein späterer Import das nicht verletzen kann:
```sql
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" 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
);
```
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
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
erklärbar.
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
---
## 2. `Dokument.ocrStatus` ist ein freier String
**Stand:** `ocrStatus String?` mit dem Kommentar
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
**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
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
heute erst auf, wenn das Abzeichen leer bleibt.
---
## 3. Kein Index auf `Dokument.objektSchluessel`
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
---
## 4. Keine Dublettenerkennung über die Prüfsumme
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
**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
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
---
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
Verknüpfen muss deshalb über die Beziehung gehen
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
passieren.
---
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
OCR-Lauf zuletzt versucht wurde, steht nirgends.
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
bei jedem Nachziehen erneut durch Tesseract zu schicken.
---
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
festgehalten gehört. Bei `Dokument.ocrText` steht:
> „die Datei selbst liegt verschlüsselt im Objektspeicher“
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
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.
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,
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
berührt.
---
# Anhang: kein Schema-Wunsch, aber dringend
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
also auch die Berichte aus Etappe 5.
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
```
TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
at @react-pdf/textkit/lib/textkit.js:3090
```
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
schuld: in reinem Node reproduziert es genauso.
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
inzwischen weitergewandert:
| Paket | installiert | von renderer 4.1.6 erwartet |
|---|---|---|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` |
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
| `@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
löste die Schicht darunter den bloßen Namen selbst auf.
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
beiden klafft eine Lücke.
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
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
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
„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):
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
```jsonc
"overrides": {
"@react-pdf/layout": "4.2.x",
"@react-pdf/textkit": "5.x"
}
```
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
Unterabhängigkeiten wieder zusammenpassen. Danach kann
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
# Schema-Wünsche aus Etappe 3 „Papier“
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
funktioniert, wäre aber sauberer, wenn es im Modell stünde.
Reihenfolge: was am meisten weh tut, steht oben.
---
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
Prüfung in `dokumentVerknuepfen`).
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
Seed oder ein späterer Import das nicht verletzen kann:
```sql
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" 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
);
```
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
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
erklärbar.
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
---
## 2. `Dokument.ocrStatus` ist ein freier String
**Stand:** `ocrStatus String?` mit dem Kommentar
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
**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
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
heute erst auf, wenn das Abzeichen leer bleibt.
---
## 3. Kein Index auf `Dokument.objektSchluessel`
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
---
## 4. Keine Dublettenerkennung über die Prüfsumme
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
**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
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
---
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
Verknüpfen muss deshalb über die Beziehung gehen
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
passieren.
---
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
OCR-Lauf zuletzt versucht wurde, steht nirgends.
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
bei jedem Nachziehen erneut durch Tesseract zu schicken.
---
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
festgehalten gehört. Bei `Dokument.ocrText` steht:
> „die Datei selbst liegt verschlüsselt im Objektspeicher“
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
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.
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,
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
berührt.
---
# Anhang: kein Schema-Wunsch, aber dringend
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
also auch die Berichte aus Etappe 5.
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
```
TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
at @react-pdf/textkit/lib/textkit.js:3090
```
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
schuld: in reinem Node reproduziert es genauso.
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
inzwischen weitergewandert:
| Paket | installiert | von renderer 4.1.6 erwartet |
|---|---|---|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` |
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
| `@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
löste die Schicht darunter den bloßen Namen selbst auf.
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
beiden klafft eine Lücke.
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
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
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
„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):
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
```jsonc
"overrides": {
"@react-pdf/layout": "4.2.x",
"@react-pdf/textkit": "5.x"
}
```
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
Unterabhängigkeiten wieder zusammenpassen. Danach kann
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
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“
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
Behelf, der jetzt an seiner Stelle steht.
---
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
Sicht des Moduls, das die Gegenüberstellung baut.
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
Pflegegrad 3 um 1.319 Euro im Monat.
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in
`src/lib/sozialhilfe/heimkosten.ts`):
```
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
+ unterkunftVerpflegung + investitionskosten
+ ausbildungsumlage + zusatzleistungen
+ barbetrag + bekleidungspauschale
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
+ sozialhilfe + eigenmittel
Deckungslücke = Bedarf − Deckung
```
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
auf der Heimrechnung als eigene Abzugsposition.
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
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
speichern.
**Wunsch, in dieser Reihenfolge:**
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
nicht nur hier:
```prisma
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
/// als Deckung, sondern nur nachrichtlich.
pflegeEigenanteilCent BigInt @default(0)
```
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
Modul raten, was in der Zeile steht:
```prisma
enum Kostenlesart {
EIGENANTEIL_NETTO
PFLEGESATZ_BRUTTO
}
lesart Kostenlesart @default(EIGENANTEIL_NETTO)
```
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
die Summe könnte gegen sie geprüft werden.
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
Eigenanteil ein und lässt `pflegekasseCent` auf 0.
---
## 2. `Nachforderung.checklistenSchluessel` — wichtig
```prisma
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
checklistenSchluessel String?
@@unique([antragId, checklistenSchluessel])
```
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
entsteht beim nächsten Übernehmen eine Dublette.
**Behelf**: Vergleich über die Bezeichnung in
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
---
## 3. Rückkaufswert am Vertrag — wichtig
```prisma
model Vertrag {
// ...
rueckkaufswertCent BigInt?
rueckkaufswertStichtag DateTime? @db.Date
}
```
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
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
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
zusätzlich ein Vermögensposten angelegt wurde.
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
Wert haben können (`MIT_RUECKKAUFSWERT` aus
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
---
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
```prisma
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
rechtsbehelfsbelehrungVorhanden Boolean @default(true)
rechtsbehelfsbelehrungBemerkung String?
```
**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“).
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
wenn man sich im Widerspruchsverfahren darauf beruft.
**Behelf**: `belehrungZuMonaten()` in
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
Weiteres in `notizenVerschluesselt`.
---
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
```prisma
widerspruchFristId String? @unique
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
```
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
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
mitziehen. Dasselbe gilt für
`"sozialhilfe-nachforderung-<nachforderungId>"`.
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
---
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
```prisma
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
gezahltCent BigInt?
gezahltAm DateTime? @db.Date
rechnungsnummer String?
```
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
Träger nachzufragen.
**Behelf**: `bemerkung`.
---
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
```prisma
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
abwesenheitsminderungCent BigInt @default(0)
```
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
nicht festhalten.
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
---
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
```prisma
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
antragsschlussMonat Int?
antragsschlussTag Int?
```
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
statt sie von Hand anzulegen.
**Behelf**: `antragsschluss` als Text im Katalog
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
---
## 9. `Nachforderung.notizenVerschluesselt` — klein
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
verschlüsselt werden sollen, gehört `Nachforderung` in
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
# Schema-Wünsche aus Etappe 5 „Behörde“
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
Behelf, der jetzt an seiner Stelle steht.
---
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
Sicht des Moduls, das die Gegenüberstellung baut.
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
Pflegegrad 3 um 1.319 Euro im Monat.
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in
`src/lib/sozialhilfe/heimkosten.ts`):
```
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
+ unterkunftVerpflegung + investitionskosten
+ ausbildungsumlage + zusatzleistungen
+ barbetrag + bekleidungspauschale
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
+ sozialhilfe + eigenmittel
Deckungslücke = Bedarf − Deckung
```
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
auf der Heimrechnung als eigene Abzugsposition.
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
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
speichern.
**Wunsch, in dieser Reihenfolge:**
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
nicht nur hier:
```prisma
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
/// als Deckung, sondern nur nachrichtlich.
pflegeEigenanteilCent BigInt @default(0)
```
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
Modul raten, was in der Zeile steht:
```prisma
enum Kostenlesart {
EIGENANTEIL_NETTO
PFLEGESATZ_BRUTTO
}
lesart Kostenlesart @default(EIGENANTEIL_NETTO)
```
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
die Summe könnte gegen sie geprüft werden.
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
Eigenanteil ein und lässt `pflegekasseCent` auf 0.
---
## 2. `Nachforderung.checklistenSchluessel` — wichtig
```prisma
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
checklistenSchluessel String?
@@unique([antragId, checklistenSchluessel])
```
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
entsteht beim nächsten Übernehmen eine Dublette.
**Behelf**: Vergleich über die Bezeichnung in
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
---
## 3. Rückkaufswert am Vertrag — wichtig
```prisma
model Vertrag {
// ...
rueckkaufswertCent BigInt?
rueckkaufswertStichtag DateTime? @db.Date
}
```
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
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
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
zusätzlich ein Vermögensposten angelegt wurde.
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
Wert haben können (`MIT_RUECKKAUFSWERT` aus
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
---
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
```prisma
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
rechtsbehelfsbelehrungVorhanden Boolean @default(true)
rechtsbehelfsbelehrungBemerkung String?
```
**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“).
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
wenn man sich im Widerspruchsverfahren darauf beruft.
**Behelf**: `belehrungZuMonaten()` in
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
Weiteres in `notizenVerschluesselt`.
---
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
```prisma
widerspruchFristId String? @unique
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
```
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
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
mitziehen. Dasselbe gilt für
`"sozialhilfe-nachforderung-<nachforderungId>"`.
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
---
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
```prisma
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
gezahltCent BigInt?
gezahltAm DateTime? @db.Date
rechnungsnummer String?
```
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
Träger nachzufragen.
**Behelf**: `bemerkung`.
---
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
```prisma
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
abwesenheitsminderungCent BigInt @default(0)
```
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
nicht festhalten.
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
---
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
```prisma
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
antragsschlussMonat Int?
antragsschlussTag Int?
```
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
statt sie von Hand anzulegen.
**Behelf**: `antragsschluss` als Text im Katalog
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
---
## 9. `Nachforderung.notizenVerschluesselt` — klein
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
verschlüsselt werden sollen, gehört `Nachforderung` in
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Die eigene Anschrift im Schreiben — gegen die echte Anwendung.
*
* Der Weg, um den es geht: einmal unter „Mein Konto" hinterlegen, und
* ab da steht sie im Absenderblock und der Ort in der Datumszeile —
* ohne dass man beim Schreiben daran denken muss.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/** Zurück auf „nichts hinterlegt", damit der Fall auch prüfbar bleibt. */
async function anschriftLeeren(): Promise<void> {
await mitDb(async (db) => {
await db.user.updateMany({ data: { strasse: null, plz: null, ort: null } })
})
}
test.describe('Absenderanschrift', () => {
test.beforeAll(async () => {
await anschriftLeeren()
})
test.afterAll(async () => {
await anschriftLeeren()
})
test('ohne hinterlegte Anschrift steht nur der Name im Brief', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
// Erste Zeile ist der Absender, danach kommt gleich die Leerzeile.
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('')
// Kein Ort in der Datumszeile, also auch kein Komma davor.
expect(text).not.toContain(', den ')
})
test('speichert die Anschrift unter Mein Konto', async ({ page }) => {
await anmelden(page)
await page.goto('/konto')
await page.locator('input#strasse').fill('Cottenbacher Str. 23')
await page.locator('input#plz').fill('95445')
await page.locator('input#ort').fill('Bayreuth')
await page.getByRole('button', { name: 'Anschrift speichern' }).click()
await expect(page.getByText('Deine Anschrift ist gespeichert.')).toBeVisible()
await page.reload()
await expect(page.locator('input#strasse')).toHaveValue('Cottenbacher Str. 23')
})
test('trägt sie in Absenderblock und Datumszeile des Schreibens', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('Cottenbacher Str. 23')
expect(zeilen[2]).toBe('95445 Bayreuth')
expect(text).toContain('Bayreuth, den ')
})
test('steht auch im Rückkaufswert-Schreiben zum Vertrag', async ({ page }) => {
const vertragId = await mitDb(async (db) => {
const vertrag = await db.vertrag.findFirst({
where: { art: 'LEBENSVERSICHERUNG', geloeschtAm: null },
select: { id: true },
})
return vertrag?.id ?? ''
})
test.skip(vertragId === '', 'Kein Lebensversicherungsvertrag im Bestand.')
await anmelden(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Cottenbacher Str. 23')
expect(text).toContain('Bayreuth, den ')
})
})
+138
View File
@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Demo-Fall mit Kontobewegungen — gegen die echte Anwendung.
*
* Was hier geprüft wird, ist kein Verhalten der Anwendung, sondern die
* Brauchbarkeit der Beispieldaten: Wer die Anwendung zum ersten Mal
* ansieht, soll auf den Finanzseiten etwas vorfinden und nicht erst
* eine CSV importieren müssen.
*
* Die Prüfungen sind bewusst grob gehalten — sie sollen anschlagen, wenn
* der Demo-Fall leer läuft, und nicht bei jeder geänderten Zahl.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
test.describe('Demo-Fall', () => {
test('bringt ein Jahr Kontobewegungen mit', async () => {
const zahlen = await mitDb(async (db) => {
const betreute = await db.betreute.findFirst({
where: { vorname: 'Erika', nachname: 'Musterfrau' },
select: { id: true },
})
if (!betreute) return null
const buchungen = await db.buchung.findMany({
where: { betreuteId: betreute.id, geloeschtAm: null },
select: { datum: true },
orderBy: { datum: 'asc' },
})
const ohneNachweis = await db.buchung.count({
where: { betreuteId: betreute.id, belegId: null, belegFehltBegruendung: null },
})
return { buchungen, ohneNachweis }
})
test.skip(zahlen === null, 'Kein Demo-Fall in dieser Datenbank.')
if (!zahlen) return
expect(zahlen.buchungen.length).toBeGreaterThan(50)
// Über mindestens zwölf Monate verteilt, nicht alle an einem Tag.
const erste = zahlen.buchungen[0]!.datum
const letzte = zahlen.buchungen[zahlen.buchungen.length - 1]!.datum
const monate =
(letzte.getUTCFullYear() - erste.getUTCFullYear()) * 12 +
(letzte.getUTCMonth() - erste.getUTCMonth())
expect(monate).toBeGreaterThanOrEqual(11)
// Genau ein Fall ohne Beleg und ohne Begründung — sonst hätte die
// Belegpflicht nichts zu zeigen, oder zu viel.
expect(zahlen.ohneNachweis).toBe(1)
})
test('der Kontostand stimmt mit der Summe der Buchungen überein', async () => {
const ergebnis = await mitDb(async (db) => {
const konto = await db.konto.findFirst({
where: { art: 'GIRO', betreute: { vorname: 'Erika', nachname: 'Musterfrau' } },
select: { id: true },
})
if (!konto) return null
const staende = await db.saldostand.findMany({
where: { kontoId: konto.id },
orderBy: { stichtag: 'asc' },
})
if (staende.length < 2) return null
const [anfang, ende] = [staende[0]!, staende[staende.length - 1]!]
const bewegungen = await db.buchung.findMany({
where: {
kontoId: konto.id,
geloeschtAm: null,
datum: { gt: anfang.stichtag, lte: ende.stichtag },
},
select: { betragCent: true, richtung: true },
})
const summe = bewegungen.reduce(
(wert, b) => (b.richtung === 'EINNAHME' ? wert + b.betragCent : wert - b.betragCent),
0n,
)
return { erwartet: anfang.betragCent + summe, tatsaechlich: ende.betragCent }
})
test.skip(ergebnis === null, 'Kein Demo-Fall mit zwei Kontoständen.')
if (!ergebnis) return
// Ein Demo-Fall, der sich selbst widerspricht, ist schlimmer als
// einer ohne Buchungen: Man rechnet nach und verliert das Vertrauen.
expect(ergebnis.tatsaechlich).toBe(ergebnis.erwartet)
})
test('die Erkennung wiederkehrender Zahlungen findet mehrere Gruppen', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const inhalt = await page.locator('body').innerText()
// Monatliches und Vierteljährliches, beides erkannt.
expect(inhalt).toContain('Seniorenzentrum')
expect(inhalt).toContain('TeleCare')
expect(inhalt).toContain('Beitragsservice')
expect(inhalt).toContain('monatlich')
expect(inhalt).toContain('vierteljährlich')
// Der eigentliche Vorführeffekt: Das Heim steht in drei Schreibweisen
// auf dem Auszug und wird trotzdem als eine Zahlung geführt.
expect(inhalt).toContain('3 Schreibweisen zusammengefasst')
// Die Sterbekasse steht im zweiten Abschnitt: Zu ihr gibt es einen
// Vertrag, die Zahlungen sind ihm aber noch nicht zugeordnet. Damit
// lässt sich der Handgriff aus der Dauerzahlungs-Zuordnung am
// Demo-Fall ausprobieren, statt ihn nur zu beschreiben.
expect(inhalt).toContain('Schon einem Vertrag zugeordnet')
expect(inhalt).toContain('Nordheim Sterbekasse')
expect(inhalt).toContain('als belegt zuordnen')
})
test('das Kassenbuch zeigt die Buchungen und die fehlende Quittung', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Heimentgelt')
// Die eine Buchung ohne Quittung und ohne Begründung.
expect(inhalt).toContain('Modehaus Lindner')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Verweise über Aktengrenzen hinweg — gegen die echte Anwendung.
*
* Die Auswahllisten im Browser zeigen nur die eigene Akte. Darauf darf
* sich der Server nicht verlassen: Wer das Formular verändert, schickt
* jede beliebige Id.
*
* Genau das wird hier nachgestellt — nicht als nachgebauter Aufruf,
* sondern indem dem echten Auswahlfeld ein Eintrag mit einer fremden Id
* untergeschoben und das Formular ganz normal abgeschickt wird. Nur so
* beweist der Test etwas: Er liefe rot, wenn die Prüfung fehlte.
*
* Solange nur eine betreute Person angelegt ist, wäre der Fehler
* folgenlos. Er bliebe es nicht — ein fremder Name stünde im Briefkopf.
*/
const KENNUNG = 'Prüfhausen Fremdakte'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.betreute.deleteMany({ where: { nachname: { contains: 'Fremdakte' } } })
})
}
test.describe('Verweise auf eine fremde Akte', () => {
test.beforeAll(async () => {
await aufraeumen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('weist einen Kontakt aus einer anderen Akte ab', async ({ page }) => {
// Zweite Akte mit einem Kontakt darin — der angemeldete Benutzer hat
// auf sie keinen Zugriff.
const fremderKontakt = await mitDb(async (db) => {
const fremde = await db.betreute.create({
data: {
vorname: 'Otto',
nachname: `${KENNUNG} Fremdakte`,
geburtsdatum: new Date(Date.UTC(1940, 0, 1)),
},
})
const kontakt = await db.kontakt.create({
data: {
betreuteId: fremde.id,
name: `${KENNUNG} Fremder Sachbearbeiter`,
rolle: 'SACHBEARBEITER_BEZIRK',
},
})
return kontakt.id
})
await anmelden(page)
// Irgendeinen Vorgang der eigenen Akte öffnen.
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { geloeschtAm: null, betreute: { vorname: 'Erika' } },
select: { id: true },
})
return vorgang?.id ?? ''
})
test.skip(vorgangId === '', 'Kein Vorgang in der eigenen Akte.')
await page.goto(`/vorgaenge/${vorgangId}`)
const auswahl = page.locator('select[name="ansprechpartnerId"]')
await auswahl.waitFor()
// Dem echten Auswahlfeld die fremde Id unterschieben und ganz normal
// abschicken — so, wie es ein verändertes Formular täte.
await auswahl.evaluate((feld, id) => {
const eintrag = document.createElement('option')
eintrag.value = id
eintrag.textContent = 'untergeschoben'
;(feld as HTMLSelectElement).append(eintrag)
;(feld as HTMLSelectElement).value = id
}, fremderKontakt)
await page.getByRole('button', { name: 'Speichern' }).click()
// Der Server lehnt ab, mit einer Meldung, die den Grund nennt.
// Grosszuegige Frist: Im Entwicklungsmodus wird die Server Action beim
// ersten Aufruf uebersetzt, und das dauert laenger als die Vorgabe.
await expect(page.getByText(/gehört nicht zu dieser betreuten Person/).first()).toBeVisible({
timeout: 45_000,
})
// Und vor allem: Die Verknüpfung ist nicht entstanden.
const verknuepft = await mitDb((db) =>
db.vorgang.count({ where: { ansprechpartnerId: fremderKontakt } }),
)
expect(verknuepft).toBe(0)
})
test('die Auswahlliste zeigt den fremden Kontakt gar nicht erst', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
const inhalt = await page.locator('body').innerText()
expect(inhalt).not.toContain('Fremder Sachbearbeiter')
})
})
+43
View File
@@ -117,3 +117,46 @@ export function beispielPdf(text = 'Bescheid'): Buffer {
return Buffer.from(pdf, 'latin1')
}
/**
* Doppelt gerenderte Listen — was beim Suchen schiefgeht.
*
* Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert
* denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab
* Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht
* aber im DOM.
*
* Die Folge im Test:
*
* await expect(page.getByText('Bescheid').first()).toBeVisible()
* // -> "element is not visible", obwohl die Seite richtig aussieht
*
* `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte,
* also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der
* Anwendung.
*
* Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16
* Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar.
*
* Drei Wege, die tragen:
*
* 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht:
*
* const inhalt = await page.locator('body').innerText()
* expect(inhalt).toContain('Bescheid')
*
* innerText liefert nur sichtbaren Text - die versteckte Fassung
* faellt von selbst weg. (Fuer Inhalt in einem zugeklappten
* <details> braucht es dagegen textContent.)
*
* 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird:
*
* page.getByRole('link', { name: 'Bescheid' }).locator('visible=true')
*
* 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um
* Anzahlen geht - die steht nur einmal.
*
* Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet
* jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt
* einen Test, der bei jeder Layoutaenderung umfaellt.
*/
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.9.0",
"version": "1.10.2",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
@@ -17,6 +17,8 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:migration": "node scripts/migration-erzeugen.mjs",
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
@@ -2,10 +2,11 @@
--
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
-- jeder erzeugten Migration entfernt.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
@@ -2,10 +2,11 @@
--
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "zahlungsgruppe_geklaert" (
@@ -2,10 +2,11 @@
--
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma
-- loeschen. Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- AlterTable
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
@@ -1,8 +1,9 @@
-- Eigene Schreibvorlagen.
--
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "schreibvorlage" (
@@ -1,8 +1,9 @@
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
--
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR');
@@ -0,0 +1,14 @@
-- betreuer_anschrift
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "ort" TEXT,
ADD COLUMN "plz" TEXT,
ADD COLUMN "strasse" TEXT;
+7
View File
@@ -81,6 +81,13 @@ model User {
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
sitzungsGeneration Int @default(0)
/// Die eigene Anschrift - Absenderblock und Ort in der Datumszeile
/// jedes erzeugten Schreibens. Sie gehoert zum Betreuer und nicht zur
/// betreuten Person: Sie gilt fuer alle Akten.
strasse String?
plz String?
ort String?
// Oberflaechen-Einstellungen
theme String @default("system") // "system" | "hell" | "dunkel"
+307 -2
View File
@@ -22,7 +22,7 @@
*
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/
import { PrismaClient } from '@prisma/client'
import { PrismaClient, type BuchungKategorie, type Prisma } from '@prisma/client'
import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto'
@@ -263,9 +263,11 @@ async function demofall(userId: string) {
art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: {
// Nur der Anfangsstand. Der spaetere wird weiter unten aus den
// Buchungen gerechnet - ein Demofall, der sich selbst
// widerspricht, waere schlimmer als einer ohne Buchungen.
create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
{ stichtag: datum('2026-07-31'), betragCent: cent(2967.11), quelle: 'Kontoauszug 07/2026' },
],
},
},
@@ -523,6 +525,309 @@ async function demofall(userId: string) {
})
}
// --- Kontobewegungen ----------------------------------------------------
// Zwoelf Monate auf dem Girokonto. Erzeugt aus einem Muster und nicht
// einzeln abgetippt: So bleibt der Fall aenderbar, und die Betraege
// widersprechen sich nicht.
//
// Die Gegenstellen sind absichtlich uneinheitlich geschrieben. Genau
// damit muss die Zusammenfassung wiederkehrender Zahlungen umgehen - auf
// einem Kontoauszug steht dieselbe Stelle auch mal in Grossbuchstaben.
type Buchungsvorlage = {
tag: number
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstellen: string[]
zweck: string
}
const MONATLICH: Buchungsvorlage[] = [
{
tag: 1,
betrag: 1284.17,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: [
'Deutsche Rentenversicherung Bund',
'DRV BUND',
'Deutsche Rentenversich. Bund',
],
zweck: 'Altersrente',
},
{
tag: 1,
betrag: 96.4,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: ['Zusatzversorgungskasse Bayern', 'ZVK Bayern'],
zweck: 'Betriebsrente',
},
{
tag: 3,
betrag: 1262,
richtung: 'EINNAHME',
kategorie: 'PFLEGEKASSE',
gegenstellen: ['Pflegekasse bei der AOK Bayern', 'AOK Bayern - Pflegekasse'],
zweck: 'Leistung vollstationaere Pflege, Pflegegrad 3',
},
{
tag: 5,
betrag: 2489,
richtung: 'AUSGABE',
kategorie: 'HEIMKOSTEN',
gegenstellen: [
'Seniorenzentrum St. Katharina',
'SENIORENZENTRUM ST KATHARINA',
'Seniorenzentrum St. Katharina gGmbH',
],
zweck: 'Heimentgelt',
},
{
tag: 5,
betrag: 135,
richtung: 'AUSGABE',
kategorie: 'BARBETRAG',
// Eigene Gegenstelle, nicht das Heim: Sonst faellt der Barbetrag
// mit dem Heimentgelt in eine Gruppe, und aus zwei sauberen
// monatlichen Zahlungen wird eine "schwankende".
gegenstellen: ['Barauszahlung Verwahrgeldkonto'],
zweck: 'Barbetrag zur persoenlichen Verfuegung',
},
{
tag: 15,
betrag: 18.4,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstellen: ['Nordheim Sterbekasse VVaG', 'NORDHEIM STERBEKASSE', 'Nordheim Sterbekasse'],
zweck: 'Beitrag Sterbegeldversicherung SG-220417',
},
{
tag: 20,
betrag: 24.9,
richtung: 'AUSGABE',
kategorie: 'TELEFON_INTERNET',
gegenstellen: ['TeleCare Kommunikation GmbH', 'TELECARE KOMMUNIKATION'],
zweck: 'Telefon und Fernsehen im Zimmer',
},
{
tag: 28,
betrag: 4.9,
richtung: 'AUSGABE',
kategorie: 'BANKGEBUEHREN',
gegenstellen: ['Sparkasse Bayreuth'],
zweck: 'Kontofuehrung',
},
]
/** Zwoelf Monate bis einschliesslich Juli 2026. */
const MONATE = [
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
]
const buchungen: Prisma.BuchungCreateManyInput[] = []
MONATE.forEach((monat, lauf) => {
MONATLICH.forEach((vorlage, stelle) => {
// Die Sterbegeld-Lastschrift steigt im Mai 2026. Daran wird die
// Pruefung beim Zuordnen einer Dauerzahlung sichtbar: Der Betrag
// weicht vom hinterlegten Beitrag ab.
const betrag =
vorlage.kategorie === 'VERSICHERUNG' && monat >= '2026-05' ? 19.8 : vorlage.betrag
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(`${monat}-${String(vorlage.tag).padStart(2, '0')}`),
betragCent: cent(betrag),
richtung: vorlage.richtung,
kategorie: vorlage.kategorie,
gegenstelle: vorlage.gegenstellen[lauf % vorlage.gegenstellen.length],
verwendungszweckVerschluesselt: verschluesseln(
vorlage.zweck,
'verwendungszweckVerschluesselt',
),
// Wiederkehrende Zahlungen brauchen keinen Einzelbeleg - der
// Bescheid bzw. der Vertrag dahinter ist der Nachweis.
belegFehltBegruendung:
vorlage.richtung === 'EINNAHME'
? 'Laufender Bezug, Nachweis ist der Bescheid.'
: 'Wiederkehrende Zahlung aus laufendem Vertrag.',
// Die Stelle in der Vorlagenliste gehoert in die Referenz: Zwei
// Renten am Monatsersten haetten sonst dieselbe, und der
// Unique-Index gegen Doppelbuchungen schlaegt zu Recht zu.
importReferenz: `demo-${monat}-${stelle}-${vorlage.kategorie}`,
})
})
})
// Vierteljaehrliches und Einmaliges - damit nicht alles im Monatsraster
// liegt und die Belegpflicht etwas zu zeigen hat.
const EINZELN: {
datum: string
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstelle: string
zweck: string
begruendung?: string
}[] = [
{
datum: '2025-08-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 3. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2025-11-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 4. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 1. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-05-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 2. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 1. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-05-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 2. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-01-19',
betrag: 240,
richtung: 'AUSGABE',
kategorie: 'IMMOBILIE_BETRIEBSKOSTEN',
gegenstelle: 'Stadtwerke Bayreuth',
zweck: 'Abschlag Strom und Wasser, Haus Bindlach',
begruendung: 'Jahresabrechnung steht noch aus.',
},
{
datum: '2026-03-11',
betrag: 10,
richtung: 'AUSGABE',
kategorie: 'ZUZAHLUNG',
gegenstelle: 'Apotheke am Markt',
zweck: 'Zuzahlung Rezept',
begruendung: 'Quittung liegt in der Akte.',
},
// Der Fall fuer die Belegpflicht: keine Quittung und keine Begruendung.
// Genau so sieht eine Buchung aus, die noch Arbeit macht.
{
datum: '2026-04-08',
betrag: 89.95,
richtung: 'AUSGABE',
kategorie: 'BEKLEIDUNG',
gegenstelle: 'Modehaus Lindner',
zweck: 'Winterjacke und zwei Nachthemden',
},
{
datum: '2026-06-17',
betrag: 23,
richtung: 'AUSGABE',
kategorie: 'SONSTIGE_AUSGABE',
gegenstelle: 'Friseur im Haus',
zweck: 'Friseur',
begruendung: 'Bar aus dem Barbetrag bezahlt, keine Quittung ausgestellt.',
},
{
datum: '2026-07-02',
betrag: 62.4,
richtung: 'EINNAHME',
kategorie: 'ZINSEN',
gegenstelle: 'Sparkasse Bayreuth',
zweck: 'Zinsgutschrift',
begruendung: 'Der Kontoauszug ist der Nachweis.',
},
]
for (const eintrag of EINZELN) {
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(eintrag.datum),
betragCent: cent(eintrag.betrag),
richtung: eintrag.richtung,
kategorie: eintrag.kategorie,
gegenstelle: eintrag.gegenstelle,
verwendungszweckVerschluesselt: verschluesseln(
eintrag.zweck,
'verwendungszweckVerschluesselt',
),
belegFehltBegruendung: eintrag.begruendung ?? null,
importReferenz: `demo-${eintrag.datum}-${eintrag.kategorie}`,
})
}
await db.buchung.createMany({ data: buchungen })
// Der Kontostand zum 31.07.2026 wird gerechnet, nicht behauptet: Anfangs-
// stand plus alles, was seither ueber das Konto gelaufen ist. Sonst
// widerspraeche der Demofall sich selbst, sobald jemand nachrechnet.
const bewegungCent = buchungen
.filter((eintrag) => {
const tag = eintrag.datum as Date
return tag > datum('2026-02-14') && tag <= datum('2026-07-31')
})
.reduce((summe, eintrag) => {
const betrag = eintrag.betragCent as bigint
return eintrag.richtung === 'EINNAHME' ? summe + betrag : summe - betrag
}, 0n)
await db.saldostand.create({
data: {
kontoId: giro.id,
stichtag: datum('2026-07-31'),
betragCent: cent(4218.36) + bewegungCent,
quelle: 'Kontoauszug 07/2026',
},
})
console.log('Demofall "Erika Musterfrau" angelegt.')
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Typen fuer das Werkzeugskript.
*
* Das Skript selbst bleibt .mjs - es laeuft ohne Bauschritt, auch auf
* einem Rechner, auf dem nur Node steht. Die Typen daneben sorgen dafuer,
* dass die Tests trotzdem geprueft werden und nicht stillschweigend
* gegen "any" laufen.
*/
export type GeschuetzterIndex = {
name: string
quelle: string
}
export declare const GESCHUETZTE_INDIZES: GeschuetzterIndex[]
export declare function dropZeilenEntfernen(
sql: string,
geschuetzt?: GeschuetzterIndex[],
): { sql: string; entfernt: string[] }
export declare function hatAnweisungen(sql: string): boolean
export declare function kopfBauen(
name: string,
entfernt: string[],
geschuetzt?: GeschuetzterIndex[],
): string
export declare function ordnernameBauen(name: string, jetzt?: Date): string
export declare function indizesPruefen(): void
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* Eine Migration erzeugen, ohne die Volltextsuche kaputtzumachen.
*
* npm run db:migration -- ansprechpartner
* npm run db:migration -- ansprechpartner --anwenden
*
* Das Problem, das dieses Skript loest: "prisma migrate diff" kennt nur
* das, was im Prisma-Schema steht. Die Trigramm-Indizes der Suche stehen
* aber in prisma/sql/volltextsuche.sql und sind Prisma unbekannt - also
* haelt es sie fuer ueberfluessig und schreibt in JEDE erzeugte Migration:
*
* DROP INDEX "dokument_absender_trgm";
* DROP INDEX "vorgang_titel_trgm";
*
* Fuenfmal ist das passiert, fuenfmal mussten die Zeilen von Hand raus.
* Auf dem Server waere es nicht aufgefallen, weil update.sh die Indizes
* hinterher ohnehin neu anlegt - in der Entwicklung dagegen waere die
* Suche still langsam geworden, ohne dass etwas fehlschlaegt.
*
* Was das Skript macht:
* 1. Migration mit "migrate diff" erzeugen.
* 2. DROP-Zeilen zu geschuetzten Indizes entfernen und im Kopf
* vermerken, welche das waren.
* 3. Auf Wunsch (--anwenden) "migrate deploy" laufen lassen und danach
* pruefen, dass die geschuetzten Indizes wirklich noch stehen.
*
* Nicht automatisch angewendet wird die Migration ohne --anwenden: Erst
* liest man, was da erzeugt wurde.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), '..')
/**
* Indizes, die es nur in prisma/sql/ gibt und die Prisma deshalb nicht
* kennt. Wer dort einen hinzufuegt, traegt ihn hier ein - sonst faellt er
* der naechsten Migration zum Opfer.
*/
export const GESCHUETZTE_INDIZES = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
/**
* Entfernt DROP-INDEX-Anweisungen zu geschuetzten Indizes.
*
* Bewusst eng gefasst: Nur eine Anweisung, die genau einen geschuetzten
* Index nennt, faellt weg. Ein DROP INDEX auf einen Prisma-eigenen Index
* bleibt stehen - der soll ja weg. Der Kommentar "-- DropIndex", den
* Prisma darueber setzt, wird mitgenommen, damit keine verwaisten
* Ueberschriften zurueckbleiben.
*
* @param {string} sql
* @param {{name: string}[]} geschuetzt
* @returns {{sql: string, entfernt: string[]}}
*/
export function dropZeilenEntfernen(sql, geschuetzt = GESCHUETZTE_INDIZES) {
const namen = geschuetzt.map((eintrag) => eintrag.name)
const zeilen = sql.split('\n')
const behalten = []
const entfernt = []
for (let i = 0; i < zeilen.length; i++) {
const zeile = zeilen[i]
const treffer = namen.find(
(name) => /^\s*DROP\s+INDEX\b/i.test(zeile) && zeile.includes(name),
)
if (!treffer) {
behalten.push(zeile)
continue
}
entfernt.push(treffer)
// Die "-- DropIndex"-Ueberschrift darueber mitnehmen, samt der
// Leerzeile davor - sonst bleiben Luecken im Text stehen.
while (behalten.length > 0 && /^\s*--\s*DropIndex\s*$/i.test(behalten[behalten.length - 1])) {
behalten.pop()
while (behalten.length > 0 && behalten[behalten.length - 1].trim() === '') behalten.pop()
}
// Leerzeilen, die der entfernten Anweisung folgen, ebenfalls.
while (i + 1 < zeilen.length && zeilen[i + 1].trim() === '') i++
}
return { sql: behalten.join('\n').replace(/\n{3,}/g, '\n\n').trimStart(), entfernt }
}
/**
* Steht in dem SQL ueberhaupt noch eine Anweisung?
*
* Wichtig fuer den Normalfall: Wenn Schema und Datenbank uebereinstimmen,
* besteht der ganze Diff aus den beiden bekannten DROP-INDEX-Zeilen. Nach
* dem Entfernen bleibt nichts uebrig - und eine leere Migration anzulegen
* waere schlimmer als gar keine, weil sie als angewendet in der Historie
* steht und niemand mehr weiss, wofuer sie gut war.
*
* @param {string} sql
* @returns {boolean}
*/
export function hatAnweisungen(sql) {
return sql
.split('\n')
.some((zeile) => zeile.trim() !== '' && !zeile.trim().startsWith('--'))
}
/** Der Kopfkommentar der erzeugten Datei. */
export function kopfBauen(name, entfernt, geschuetzt = GESCHUETZTE_INDIZES) {
const zeilen = [`-- ${name}`, '--']
if (entfernt.length === 0) {
zeilen.push('-- Erzeugt mit "npm run db:migration". Nichts zu entfernen.')
} else {
const quellen = [
...new Set(
entfernt.map(
(eintrag) => geschuetzt.find((g) => g.name === eintrag)?.quelle ?? 'prisma/sql/',
),
),
]
zeilen.push(
'-- Erzeugt mit "npm run db:migration". Entfernt wurden die',
'-- DROP-INDEX-Anweisungen zu diesen Indizes:',
...entfernt.map((eintrag) => `-- ${eintrag}`),
'--',
`-- Sie stammen aus ${quellen.join(', ')} und sind Prisma unbekannt;`,
'-- ohne sie waere die Volltextsuche still langsam geworden.',
)
}
zeilen.push('')
return zeilen.join('\n')
}
/** Zeitstempel im Prisma-Format, aus der lokalen Uhr. */
export function ordnernameBauen(name, jetzt = new Date()) {
const zwei = (zahl) => String(zahl).padStart(2, '0')
const stempel = [
jetzt.getFullYear(),
zwei(jetzt.getMonth() + 1),
zwei(jetzt.getDate()),
zwei(jetzt.getHours()),
zwei(jetzt.getMinutes()),
zwei(jetzt.getSeconds()),
].join('')
return `${stempel}_${name}`
}
// --- Ab hier: Ausfuehrung ----------------------------------------------------
/**
* Prisma direkt ueber sein Eintrittsskript aufrufen, nicht ueber npx.
*
* Mit "shell: true" wuerde Node warnen (Argumente werden dann nur
* aneinandergehaengt statt maskiert), und ohne Shell findet Windows das
* npx-Skript nicht. Der Weg ueber node loest beides und ist derselbe,
* den scripts/update.sh schon nimmt.
*/
function prisma(argumente, optionen = {}) {
const eintritt = join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js')
return execFileSync(process.execPath, [eintritt, ...argumente], {
cwd: WURZEL,
encoding: 'utf8',
...optionen,
})
}
function abbruch(meldung) {
console.error(`\n Abbruch: ${meldung}\n`)
process.exit(1)
}
function hauptlauf() {
const argumente = process.argv.slice(2)
const anwenden = argumente.includes('--anwenden')
const name = argumente.find((eintrag) => !eintrag.startsWith('--'))
if (!name) {
abbruch(
'Bitte einen Namen angeben: npm run db:migration -- <name> [--anwenden]\n' +
' Beispiel: npm run db:migration -- ansprechpartner',
)
}
if (!/^[a-z0-9_]+$/.test(name)) {
abbruch(`"${name}" taugt nicht als Ordnername. Erlaubt: a-z, 0-9 und _`)
}
console.log(`\n Migration "${name}" erzeugen\n`)
let roh
try {
roh = prisma([
'migrate',
'diff',
'--from-schema-datasource',
'prisma/schema.prisma',
'--to-schema-datamodel',
'prisma/schema.prisma',
'--script',
])
} catch (fehler) {
abbruch(`"prisma migrate diff" ist fehlgeschlagen:\n${fehler.message}`)
}
if (roh.trim() === '' || /^--\s*This is an empty migration/im.test(roh)) {
abbruch('Schema und Datenbank stimmen bereits ueberein - es gibt nichts zu migrieren.')
}
const { sql, entfernt } = dropZeilenEntfernen(roh)
if (!hatAnweisungen(sql)) {
const grund =
entfernt.length > 0
? `Uebrig blieben nur die bekannten DROP-INDEX-Zeilen (${entfernt.join(', ')}).`
: 'Der Vergleich hat keine Anweisungen ergeben.'
abbruch(
`Es gibt nichts zu migrieren. ${grund}\n` +
' Es wurde keine Migration angelegt.',
)
}
const ordner = join(WURZEL, 'prisma', 'migrations', ordnernameBauen(name))
mkdirSync(ordner, { recursive: true })
const datei = join(ordner, 'migration.sql')
writeFileSync(datei, `${kopfBauen(name, entfernt)}\n${sql.trimEnd()}\n`, 'utf8')
console.log(` Geschrieben: ${datei.replace(WURZEL, '.')}`)
if (entfernt.length > 0) {
console.log('\n Entfernte DROP-INDEX-Anweisungen:')
for (const eintrag of entfernt) console.log(` - ${eintrag}`)
} else {
console.log(' Keine DROP-INDEX-Anweisung zu schuetzenden Indizes gefunden.')
}
if (!anwenden) {
console.log('\n Jetzt: die Datei lesen, dann anwenden mit')
console.log(' npm run db:deploy && npm run db:pruefen\n')
return
}
console.log('\n Anwenden ...')
try {
prisma(['migrate', 'deploy'], { stdio: 'inherit' })
} catch {
abbruch('"prisma migrate deploy" ist fehlgeschlagen. Die Datei bleibt liegen.')
}
indizesPruefen()
}
/**
* Stehen die geschuetzten Indizes noch?
*
* Der Sinn des ganzen Skripts haengt an dieser Pruefung: Sie ist der
* Unterschied zwischen "sollte gutgegangen sein" und "nachgesehen".
*/
export function indizesPruefen() {
const bedingung = GESCHUETZTE_INDIZES.map((eintrag) => `'${eintrag.name}'`).join(', ')
const sql = `DO $$
BEGIN
IF (SELECT count(*) FROM pg_indexes WHERE indexname IN (${bedingung})) <> ${GESCHUETZTE_INDIZES.length} THEN
RAISE EXCEPTION 'Es fehlen geschuetzte Indizes';
END IF;
END $$;`
// In den Temp-Ordner, nicht ins Repository: Die Datei ist Werkzeug,
// kein Bestandteil des Projekts.
const datei = join(tmpdir(), `indizes-pruefung-${process.pid}.sql`)
writeFileSync(datei, sql, 'utf8')
try {
prisma(['db', 'execute', '--schema', 'prisma/schema.prisma', '--file', datei], {
stdio: 'pipe',
})
console.log('\n Geschuetzte Indizes stehen:')
for (const eintrag of GESCHUETZTE_INDIZES) console.log(` - ${eintrag.name}`)
console.log('')
} catch {
abbruch(
'Nach der Migration fehlen geschuetzte Indizes.\n' +
' Wiederherstellen mit: npm run db:sql',
)
} finally {
rmSync(datei, { force: true })
}
}
if (process.argv[1] && process.argv[1].endsWith('migration-erzeugen.mjs')) {
hauptlauf()
}
+13 -3
View File
@@ -75,9 +75,19 @@ done
# --- Selbstpruefung ----------------------------------------------------------
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| head -c 5 | grep -q "PGDMP"; then
# Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie
# genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt
# SIGPIPE - und mit "set -o pipefail" gilt die ganze Kette als
# gescheitert. Bei einer kleinen Datenbank faellt das nicht auf, weil
# alles schon im Puffer steht; mit wachsender Datenbank kippt es, und
# dann meldet die Selbstpruefung eine INTAKTE Sicherung als kaputt.
#
# "dd" liest eine feste Menge und beendet sich regulaer.
KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| dd bs=1 count=5 2>/dev/null || true)"
if [ "$KOPF" = "PGDMP" ]; then
echo "$(date -Iseconds) Selbstpruefung bestanden."
else
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
+45 -5
View File
@@ -52,6 +52,32 @@ gut() { printf ' %s%s%s\n' "$GRUEN" "$1" "$AUS"; }
warnung() { printf ' %s%s%s\n' "$GELB" "$1" "$AUS"; }
abbruch() { printf '\n%sAbbruch: %s%s\n\n' "$ROT" "$1" "$AUS" >&2; exit 1; }
# --- Unerwarteter Abbruch ----------------------------------------------------
#
# "set -e" beendet das Skript bei jedem Befehl, der nicht 0 zurueckgibt -
# stumm. Auf dem Bildschirm bricht die Ausgabe dann einfach ab, und man
# sucht an der falschen Stelle.
#
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert: Eine Leitung
# der Form "git log ... | head -15" hat den Erzeuger mit SIGPIPE beendet,
# "pipefail" hat das an die Kette weitergereicht, und das Skript war weg.
#
# Diese Falle sagt jetzt wenigstens, WO es passiert ist. Exitcode 141
# heisst SIGPIPE - dann wurde irgendwo wieder eine Leitung zu frueh
# geschlossen.
unerwartet() {
code=$1
zeile=$2
printf '%sAbbruch in Zeile %s (Exitcode %s).%s\n' "$ROT" "$zeile" "$code" "$AUS" >&2
if [ "$code" = "141" ]; then
printf '%sExitcode 141 ist SIGPIPE: eine Leitung wurde zu frueh geschlossen,%s\n' "$ROT" "$AUS" >&2
printf '%setwa durch "| head". Das ist ein Fehler im Skript, nicht bei dir.%s\n' "$ROT" "$AUS" >&2
fi
printf '%sKam der Abbruch vor Schritt 5, wurde nichts veraendert.%s\n' "$ROT" "$AUS" >&2
}
trap 'unerwartet $? $LINENO' ERR
# --- Aufrufparameter ---------------------------------------------------------
ZIEL=""
@@ -113,7 +139,11 @@ if ! git fetch --tags --quiet origin; then
fi
JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
NEUESTE="$(git tag --list 'v*' --sort=-v:refname | head -1)"
# Kein "| head -1": Siehe den Hinweis zu SIGPIPE weiter unten. Die erste
# Zeile holt die Shell selbst.
ALLE_TAGS="$(git tag --list 'v*' --sort=-v:refname)"
NEUESTE="${ALLE_TAGS%%$'
'*}"
[ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)."
@@ -123,7 +153,8 @@ fi
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|| abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden:
$(git tag --list 'v*' --sort=-v:refname | head -5 | sed 's/^/ /')"
$(printf '%s
' "$ALLE_TAGS" | sed -n '1,5p' | sed 's/^/ /')"
printf ' Jetzt: %s\n' "$JETZT"
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
@@ -152,14 +183,23 @@ if [ -n "$CHANGELOG" ]; then
if (nummer == jetzt) exit
drucken = (nummer != "Unveröffentlicht")
}
drucken { print " " $0 }
' | head -60
drucken && zeilen < 60 { print " " $0; zeilen++ }
'
else
warnung "Kein CHANGELOG.md in $ZIEL gefunden."
fi
printf '\n'
git log --oneline --no-decorate "HEAD..$ZIEL" | head -15 | sed 's/^/ /'
# "-15" statt "| head -15": Bei mehr Commits als der Grenze schliesst head
# die Leitung, git bekommt SIGPIPE, und mit "set -o pipefail" gilt die ganze
# Kette als gescheitert - "set -e" beendet das Skript dann OHNE Meldung.
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert.
ANZAHL="$(git rev-list --count "HEAD..$ZIEL")"
git log --oneline --no-decorate -15 "HEAD..$ZIEL" | sed 's/^/ /'
if [ "$ANZAHL" -gt 15 ]; then
printf ' ... und %s weitere
' "$((ANZAHL - 15))"
fi
if [ "$NUR_PRUEFEN" = "1" ]; then
printf '\n'
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from 'next/link'
import {
Archive,
CalendarClock,
FolderArchive,
Paperclip,
ScrollText,
} from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [
'anfangsbericht',
'jahresbericht',
'vermoegensverzeichnis',
'vermoegensuebersicht',
'rechnungslegung',
]
const WEGE = [
{
href: '/berichte/jahresbericht',
titel: 'Jahresbericht',
beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText,
},
{
href: '/berichte/belegmappe',
titel: 'Belegmappe',
beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive,
},
{
href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger',
beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip,
},
{
href: '/berichte/export',
titel: 'Vollexport',
beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive,
},
]
/**
* Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
*
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/
export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([
db.frist.findMany({
where: {
betreuteId,
status: 'OFFEN',
...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel },
})),
},
orderBy: { datum: 'asc' },
take: 10,
}),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
])
return (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
pfad={[{ titel: 'Berichte' }]}
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">
<h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4">
Fristen
</Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p>
) : (
<ul className="divide-y rounded-md border">
{fristen.map((frist) => {
const tage = tageBis(frist.datum)
const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return (
<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" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge>
</li>
)
})}
</ul>
)}
</section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => {
const Icon = weg.icon
return (
<Card key={weg.href}>
<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" />
<div className="min-w-0">
<CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel}
</Link>
</CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div>
</CardHeader>
</Card>
)
})}
</section>
<section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section>
<HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
import Link from 'next/link'
import {
Archive,
CalendarClock,
FolderArchive,
Paperclip,
ScrollText,
} from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [
'anfangsbericht',
'jahresbericht',
'vermoegensverzeichnis',
'vermoegensuebersicht',
'rechnungslegung',
]
const WEGE = [
{
href: '/berichte/jahresbericht',
titel: 'Jahresbericht',
beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText,
},
{
href: '/berichte/belegmappe',
titel: 'Belegmappe',
beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive,
},
{
href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger',
beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip,
},
{
href: '/berichte/export',
titel: 'Vollexport',
beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive,
},
]
/**
* Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
*
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/
export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([
db.frist.findMany({
where: {
betreuteId,
status: 'OFFEN',
...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel },
})),
},
orderBy: { datum: 'asc' },
take: 10,
}),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
])
return (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
pfad={[{ titel: 'Berichte' }]}
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">
<h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4">
Fristen
</Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p>
) : (
<ul className="divide-y rounded-md border">
{fristen.map((frist) => {
const tage = tageBis(frist.datum)
const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return (
<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" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge>
</li>
)
})}
</ul>
)}
</section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => {
const Icon = weg.icon
return (
<Card key={weg.href}>
<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" />
<div className="min-w-0">
<CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel}
</Link>
</CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div>
</CardHeader>
</Card>
)
})}
</section>
<section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section>
<HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
@@ -0,0 +1,89 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { anschriftSpeichern, type AnschriftZustand } from '@/server/actions/konto'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Anschrift speichern'}
</Button>
)
}
export function AnschriftFormular({
strasse,
plz,
ort,
}: {
strasse: string | null
plz: string | null
ort: string | null
}) {
const [zustand, aktion] = useActionState<AnschriftZustand, FormData>(anschriftSpeichern, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input
id="strasse"
name="strasse"
defaultValue={strasse ?? ''}
maxLength={150}
autoComplete="street-address"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input
id="plz"
name="plz"
defaultValue={plz ?? ''}
maxLength={10}
autoComplete="postal-code"
inputMode="numeric"
/>
</Feld>
<Feld
id="ort"
beschriftung="Ort"
erklaerung="Steht außerdem in der Datumszeile: „Bayreuth, den 23.08.2026“."
fehler={zustand.felder?.ort}
>
<Input
id="ort"
name="ort"
defaultValue={ort ?? ''}
maxLength={100}
autoComplete="address-level2"
/>
</Feld>
</div>
<Absenden />
</form>
)
}
+27 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react'
import { Fingerprint, KeyRound, MapPin, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -11,6 +11,7 @@ import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic'
@@ -22,7 +23,10 @@ export default async function KontoSeite() {
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." />
<SeitenKopf
titel="Mein Konto"
beschreibung="Zugangsdaten, eigene Anschrift, zweiter Faktor und Passkeys."
/>
<Card>
<CardHeader className="pb-3">
@@ -55,6 +59,27 @@ export default async function KontoSeite() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-5" aria-hidden="true" />
Meine Anschrift
</CardTitle>
<CardDescription>
Steht im Absenderblock jedes Schreibens, das hier entsteht, und liefert den Ort für
die Datumszeile. Ohne sie geht ein förmlicher Brief ohne Rücksendeadresse hinaus.
Jede Angabe ist einzeln freiwillig — was fehlt, bleibt im Brief einfach weg.
</CardDescription>
</CardHeader>
<CardContent>
<AnschriftFormular
strasse={benutzer.strasse}
plz={benutzer.plz}
ort={benutzer.ort}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function SchreibenSeite({
try {
entwurf = await entwurfBauen({
betreuteId: aktive.id,
benutzerName: benutzer.name,
benutzer,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
@@ -1,149 +1,149 @@
import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag'
import {
fristAmpel,
rechtsbehelfsfristBerechnen,
zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Bescheid-Prüfung.
*
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist.
*/
export default async function BescheidSeite({
params,
}: {
params: Promise<{ antragId: string; bescheidId: string }>
}) {
const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
})
if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
})
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return (
<div className="space-y-8">
<SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={
<>
{bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)}
</>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' },
]}
/>
<BescheidPruefung
bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
}
darfSchreiben={darfSchreiben}
rechenweg={{
berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null,
rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde)
: null,
verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise,
}}
/>
{offen.notizen && (
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent>
</Card>
)}
{darfSchreiben && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular
antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{
id: bescheid.id,
art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '',
notizen: offen.notizen ?? '',
}}
/>
<div className="border-t pt-6">
<AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline"
size="default"
>
<Trash2 aria-hidden="true" />
Bescheid löschen
</AktionMitRueckfrage>
</div>
</section>
)}
</div>
)
}
import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag'
import {
fristAmpel,
rechtsbehelfsfristBerechnen,
zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Bescheid-Prüfung.
*
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist.
*/
export default async function BescheidSeite({
params,
}: {
params: Promise<{ antragId: string; bescheidId: string }>
}) {
const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
})
if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
})
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return (
<div className="space-y-8">
<SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={
<>
{bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)}
</>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' },
]}
/>
<BescheidPruefung
bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
}
darfSchreiben={darfSchreiben}
rechenweg={{
berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null,
rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde)
: null,
verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise,
}}
/>
{offen.notizen && (
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent>
</Card>
)}
{darfSchreiben && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular
antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{
id: bescheid.id,
art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '',
notizen: offen.notizen ?? '',
}}
/>
<div className="border-t pt-6">
<AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline"
size="default"
>
<Trash2 aria-hidden="true" />
Bescheid löschen
</AktionMitRueckfrage>
</div>
</section>
)}
</div>
)
}
+232 -232
View File
@@ -1,232 +1,232 @@
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic'
/**
* Vermögens-Bewertungsansicht.
*
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/
export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' },
}),
db.vertrag.findMany({
where: {
betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht,
},
orderBy: { bezeichnung: 'asc' },
}),
])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
)
const zeilen: ListenPosten[] = [
...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0]
return {
id: p.id,
bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const,
art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null,
}
}),
...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => {
const geoeffnet = oeffnen('Vertrag', v)
return {
id: v.id,
bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const,
art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null,
wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft,
}
}),
]
const uebersicht = uebersichtBilden(
zeilen.map((z) => ({
id: z.id,
bezeichnung: z.bezeichnung,
herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null,
begruendung: z.begruendung,
href: '#',
})),
)
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return (
<div className="space-y-8">
<SeitenKopf
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."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' },
]}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground"
/>
</div>
<Card>
<CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span>
<span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)}
</span>
</div>
<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-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`}
>
{formatCent(vergleich.ueberFreibetragCent)}
</span>
</div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent>
</Card>
{uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>
{uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle>
<AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen.
</AlertDescription>
</Alert>
)}
{uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription>
{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.
</AlertDescription>
</Alert>
)}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div>
)
}
function Summenkachel({
titel,
betrag,
anzahl,
ohneWert,
farbe,
}: {
titel: string
betrag: bigint
anzahl: number
ohneWert: number
farbe: string
}) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p>
</CardContent>
</Card>
)
}
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic'
/**
* Vermögens-Bewertungsansicht.
*
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/
export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' },
}),
db.vertrag.findMany({
where: {
betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht,
},
orderBy: { bezeichnung: 'asc' },
}),
])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
)
const zeilen: ListenPosten[] = [
...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0]
return {
id: p.id,
bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const,
art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null,
}
}),
...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => {
const geoeffnet = oeffnen('Vertrag', v)
return {
id: v.id,
bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const,
art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null,
wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft,
}
}),
]
const uebersicht = uebersichtBilden(
zeilen.map((z) => ({
id: z.id,
bezeichnung: z.bezeichnung,
herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null,
begruendung: z.begruendung,
href: '#',
})),
)
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return (
<div className="space-y-8">
<SeitenKopf
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."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' },
]}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground"
/>
</div>
<Card>
<CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span>
<span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)}
</span>
</div>
<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-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`}
>
{formatCent(vergleich.ueberFreibetragCent)}
</span>
</div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent>
</Card>
{uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>
{uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle>
<AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen.
</AlertDescription>
</Alert>
)}
{uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription>
{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.
</AlertDescription>
</Alert>
)}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div>
)
}
function Summenkachel({
titel,
betrag,
anzahl,
ohneWert,
farbe,
}: {
titel: string
betrag: bigint
anzahl: number
ohneWert: number
farbe: string
}) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p>
</CardContent>
</Card>
)
}
@@ -1,77 +1,77 @@
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string {
if (cent === null) return ''
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const euro = betrag / 100n
const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
}
export default async function VertragBearbeiten({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' },
]}
/>
<VertragFormular
betreuteId={aktive.id}
werte={{
id: vertrag.id,
bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art,
status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '',
}}
/>
</div>
)
}
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string {
if (cent === null) return ''
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const euro = betrag / 100n
const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
}
export default async function VertragBearbeiten({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' },
]}
/>
<VertragFormular
betreuteId={aktive.id}
werte={{
id: vertrag.id,
bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art,
status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '',
}}
/>
</div>
)
}
+96 -96
View File
@@ -1,96 +1,96 @@
import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
art: vertrag.art,
laufzeit: {
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
},
heute,
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' },
]}
/>
<KuendigungsAssistent
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)}
daten={{
schreiben: paket.schreiben,
warnungen: paket.warnungen,
genehmigung: paket.genehmigung,
versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis,
},
plan: {
berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin)
: null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise,
},
}}
/>
</div>
)
}
import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
art: vertrag.art,
laufzeit: {
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
},
heute,
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' },
]}
/>
<KuendigungsAssistent
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)}
daten={{
schreiben: paket.schreiben,
warnungen: paket.warnungen,
genehmigung: paket.genehmigung,
versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis,
},
plan: {
berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin)
: null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise,
},
}}
/>
</div>
)
}
+275 -275
View File
@@ -1,275 +1,275 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import {
kuendigungsampel,
kuendigungsplan,
laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung'
import {
anfrageStatus,
rueckkaufswertAnschreiben,
wertHistorie,
} from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
jahresbeitragCent,
kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: {
where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
},
},
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const plan = kuendigungsplan({
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
heute,
})
const ampel = kuendigungsampel(plan)
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
const kopf = briefkopfBauen({ betreute: aktive, benutzer })
const anschreiben = rueckkaufswertAnschreiben({
kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
stichtag: heute,
heute,
})
const wertstaende = wertHistorie(vertrag.vermoegensposten)
return (
<div className="space-y-6">
<SeitenKopf
titel={vertrag.bezeichnung}
beschreibung={
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean)
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung },
]}
aktionen={
schreiben && (
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href={`/vertraege/${id}/kuendigen`}>
<FileX2 aria-hidden="true" />
Kündigen
</Link>
</Button>
<Button asChild>
<Link href={`/vertraege/${id}/bearbeiten`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
</div>
)
}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" aria-hidden="true" />
Vertragsdaten
</CardTitle>
<CardDescription>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y">
<Datenzeile beschriftung="Vertragsnummer">
{offen.vertragsnummer ?? '–'}
</Datenzeile>
<Datenzeile beschriftung="Status">
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile>
<Datenzeile beschriftung="Laufzeitbeginn">
{formatDatum(vertrag.beginnAm)}
</Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende">
{formatDatum(vertrag.endeAm)}
</Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist">
{vertrag.kuendigungsfristMonate !== null
? `${vertrag.kuendigungsfristMonate} Monate`
: 'nicht erfasst'}
</Datenzeile>
</dl>
<dl className="divide-y">
<Datenzeile beschriftung="Beitrag">
{formatCent(vertrag.beitragCent)}
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
</Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.gekuendigtAm)}
</Datenzeile>
<Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)}
</Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</Datenzeile>
</dl>
{offen.notizen && (
<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>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Kündigungsfrist</CardTitle>
<CardDescription>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
Absendetag früher als der Fristablauf.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{plan.berechenbar ? (
<>
<dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.zugangSpaetestensAm)}
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)}
</dd>
{ampel && (
<dd className="mt-1">
<Badge variant={ampel.variante}>{ampel.text}</Badge>
</dd>
)}
</div>
</dl>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{plan.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
</>
) : (
<p className="text-sm text-muted-foreground">
{plan.hinweise[0] ??
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
</p>
)}
</CardContent>
</Card>
{kannRueckkaufswertHaben(vertrag.art) && (
<Rueckkaufswert
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig}
tageSeitAnfrage={anfrage.tageSeitAnfrage}
anschreiben={anschreiben}
stichtagVorschlag={alsIsoTag(heute)}
alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
}
werte={wertstaende.map((w) => ({
id: w.id,
stichtag: w.stichtag.toISOString(),
wertCent: centZuZahl(w.wertCent) ?? 0,
wertquelle: w.wertquelle,
bemerkung: w.bemerkung,
}))}
/>
)}
<Card>
<CardHeader>
<CardTitle>Bewertung fürs Sozialamt</CardTitle>
<CardDescription>
Meine Einordnung und meine Begründung — keine Rechtsauskunft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground">
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p>
{offen.bewertungBegruendung ? (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{offen.bewertungBegruendung}
</p>
) : (
<p className="text-sm text-muted-foreground">
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht.
</p>
)}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent>
</Card>
</div>
)
}
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import {
kuendigungsampel,
kuendigungsplan,
laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung'
import {
anfrageStatus,
rueckkaufswertAnschreiben,
wertHistorie,
} from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
jahresbeitragCent,
kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: {
where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
},
},
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const plan = kuendigungsplan({
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
heute,
})
const ampel = kuendigungsampel(plan)
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
const kopf = briefkopfBauen({ betreute: aktive, benutzer })
const anschreiben = rueckkaufswertAnschreiben({
kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
stichtag: heute,
heute,
})
const wertstaende = wertHistorie(vertrag.vermoegensposten)
return (
<div className="space-y-6">
<SeitenKopf
titel={vertrag.bezeichnung}
beschreibung={
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean)
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung },
]}
aktionen={
schreiben && (
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href={`/vertraege/${id}/kuendigen`}>
<FileX2 aria-hidden="true" />
Kündigen
</Link>
</Button>
<Button asChild>
<Link href={`/vertraege/${id}/bearbeiten`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
</div>
)
}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" aria-hidden="true" />
Vertragsdaten
</CardTitle>
<CardDescription>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y">
<Datenzeile beschriftung="Vertragsnummer">
{offen.vertragsnummer ?? '–'}
</Datenzeile>
<Datenzeile beschriftung="Status">
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile>
<Datenzeile beschriftung="Laufzeitbeginn">
{formatDatum(vertrag.beginnAm)}
</Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende">
{formatDatum(vertrag.endeAm)}
</Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist">
{vertrag.kuendigungsfristMonate !== null
? `${vertrag.kuendigungsfristMonate} Monate`
: 'nicht erfasst'}
</Datenzeile>
</dl>
<dl className="divide-y">
<Datenzeile beschriftung="Beitrag">
{formatCent(vertrag.beitragCent)}
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
</Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.gekuendigtAm)}
</Datenzeile>
<Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)}
</Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</Datenzeile>
</dl>
{offen.notizen && (
<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>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Kündigungsfrist</CardTitle>
<CardDescription>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
Absendetag früher als der Fristablauf.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{plan.berechenbar ? (
<>
<dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.zugangSpaetestensAm)}
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)}
</dd>
{ampel && (
<dd className="mt-1">
<Badge variant={ampel.variante}>{ampel.text}</Badge>
</dd>
)}
</div>
</dl>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{plan.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
</>
) : (
<p className="text-sm text-muted-foreground">
{plan.hinweise[0] ??
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
</p>
)}
</CardContent>
</Card>
{kannRueckkaufswertHaben(vertrag.art) && (
<Rueckkaufswert
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig}
tageSeitAnfrage={anfrage.tageSeitAnfrage}
anschreiben={anschreiben}
stichtagVorschlag={alsIsoTag(heute)}
alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
}
werte={wertstaende.map((w) => ({
id: w.id,
stichtag: w.stichtag.toISOString(),
wertCent: centZuZahl(w.wertCent) ?? 0,
wertquelle: w.wertquelle,
bemerkung: w.bemerkung,
}))}
/>
)}
<Card>
<CardHeader>
<CardTitle>Bewertung fürs Sozialamt</CardTitle>
<CardDescription>
Meine Einordnung und meine Begründung — keine Rechtsauskunft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground">
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p>
{offen.bewertungBegruendung ? (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{offen.bewertungBegruendung}
</p>
) : (
<p className="text-sm text-muted-foreground">
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht.
</p>
)}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent>
</Card>
</div>
)
}
+438 -438
View File
@@ -1,438 +1,438 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
@@ -17,6 +17,21 @@ import {
*
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
*
* ACHTUNG, jeder Inhalt steht ZWEIMAL im DOM: einmal als Karte, einmal
* als Tabellenzeile. Eine der beiden Fassungen ist per Breakpoint
* versteckt, steht aber trotzdem im Baum.
*
* Fuer Tests heisst das: getByText(...).first() trifft womoeglich die
* unsichtbare Kopie, und die Pruefung scheitert mit "element is not
* visible" an einer Stelle, die augenscheinlich in Ordnung ist. Der Weg,
* der traegt, steht in e2e/hilfen.ts unter "Doppelt gerenderte Listen".
*
* Warum nicht eine Fassung fuer beide? Eine Tabelle, die sich zu Karten
* umbricht, verlangt display:block auf Zeilen und Zellen. Damit ist es
* keine Tabelle mehr - Vorlesesoftware verliert den Zusammenhang von
* Spaltenkopf und Wert, und genau der macht eine Dokumentenliste mit
* acht Spalten erst benutzbar. Die Verdopplung ist der Preis dafuer.
*/
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
return (
+310 -310
View File
@@ -1,310 +1,310 @@
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
hinweisPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/
hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
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."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse}
>
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="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."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
hinweisPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/
hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
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."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse}
>
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="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."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
+237 -237
View File
@@ -1,237 +1,237 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
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}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
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."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
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}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
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."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
+121
View File
@@ -0,0 +1,121 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Gehoert ein verknuepfter Datensatz zu dieser Akte?
*
* Wenn ein Formular einen Fremdschluessel mitschickt - Konto, Vertrag,
* Immobilie, Dokument, Vorgang, Kontakt -, dann kommt der aus einer
* Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen:
* Eine veraenderte Anfrage kann jede beliebige Id enthalten.
*
* Ohne die Pruefung entstuende eine Verknuepfung ueber Aktengrenzen
* hinweg. Was daran gefaehrlich ist, sieht man erst an den Folgen: Der
* Rueckkaufswert der einen Akte haengt am Vertrag der anderen, ein
* fremder Name steht im Briefkopf, ein fremder Beschluss in der
* Genehmigungskette.
*
* Solange nur eine betreute Person angelegt ist, ist all das folgenlos.
* Die Pruefung gehoert trotzdem hin - sie kostet eine Abfrage, und die
* zweite betreute Person kommt frueher, als man denkt.
*
* Bewusst KEINE Ausnahme geworfen: Formularaktionen geben Fehler zurueck,
* damit die Eingaben des Benutzers nicht verlorengehen.
*/
/** Die Tabellen, auf die ein Formular verweisen darf. */
export type Verweisziel =
| 'konto'
| 'vertrag'
| 'immobilie'
| 'dokument'
| 'vorgang'
| 'kontakt'
| 'vermoegensposten'
| 'buchung'
type Sucher = (id: string, betreuteId: string) => Promise<{ id: string } | null>
/**
* Je Ziel eine Abfrage, ausgeschrieben statt dynamisch zusammengesetzt.
*
* Das ist ein paar Zeilen laenger als ein Zugriff ueber den Tabellennamen -
* dafuer ist die Liste der erlaubten Ziele eine bewusste Aufzaehlung und
* keine Nebenwirkung, und der Typpruefer sieht jede Abfrage.
*/
const SUCHER: Record<Verweisziel, Sucher> = {
konto: (id, betreuteId) =>
db.konto.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vertrag: (id, betreuteId) =>
db.vertrag.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
immobilie: (id, betreuteId) =>
db.immobilie.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
dokument: (id, betreuteId) =>
db.dokument.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vorgang: (id, betreuteId) =>
db.vorgang.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
kontakt: (id, betreuteId) =>
db.kontakt.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vermoegensposten: (id, betreuteId) =>
db.vermoegensposten.findFirst({
where: { id, betreuteId, ...nichtGeloescht },
select: { id: true },
}),
buchung: (id, betreuteId) =>
db.buchung.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
}
const BESCHRIFTUNG: Record<Verweisziel, string> = {
konto: 'Dieses Konto',
vertrag: 'Dieser Vertrag',
immobilie: 'Diese Immobilie',
dokument: 'Dieses Dokument',
vorgang: 'Dieser Vorgang',
kontakt: 'Dieser Kontakt',
vermoegensposten: 'Dieser Vermögensposten',
buchung: 'Diese Buchung',
}
/**
* Prueft einen einzelnen Verweis.
*
* Ein leerer Verweis ist in Ordnung - "keine Verknuepfung" ist ein
* gueltiger Zustand und keine Verletzung.
*
* @returns null, wenn alles stimmt; sonst die fertige Fehlermeldung.
*/
export async function verweisPruefen(
ziel: Verweisziel,
id: string | null | undefined,
betreuteId: string,
): Promise<string | null> {
if (!id) return null
const treffer = await SUCHER[ziel](id, betreuteId)
return treffer ? null : `${BESCHRIFTUNG[ziel]} gehört nicht zu dieser betreuten Person.`
}
/**
* Prueft mehrere Verweise auf einmal und meldet den ersten Verstoss.
*
* Die Abfragen laufen nebeneinander: Bei vier Verweisen ist das eine
* Wartezeit statt vier.
*
* @example
* const fehler = await verweisePruefen(betreuteId, {
* konto: werte.kontoId,
* vertrag: werte.vertragId,
* })
* if (fehler) return { fehler }
*/
export async function verweisePruefen(
betreuteId: string,
verweise: Partial<Record<Verweisziel, string | null | undefined>>,
): Promise<string | null> {
const eintraege = Object.entries(verweise) as [Verweisziel, string | null | undefined][]
const ergebnisse = await Promise.all(
eintraege.map(([ziel, id]) => verweisPruefen(ziel, id, betreuteId)),
)
return ergebnisse.find((meldung) => meldung !== null) ?? null
}
+213 -213
View File
@@ -1,213 +1,213 @@
import 'server-only'
import {
AlignmentType,
BorderStyle,
Document,
Footer,
Header,
HeadingLevel,
PageNumber,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/**
* DOCX-Ausgabe.
*
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
*
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung.
*
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/
export type DocxKopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({
text,
bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
})
}
function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
]
if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
)
}
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen
}
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 },
}),
new Paragraph({
children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 },
}),
new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}),
]
}
/**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [
new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 },
}),
...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
),
new Paragraph({
children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 },
}),
]
for (const abschnitt of abschnitte) {
if (abschnitt.titel) {
inhalt.push(
new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 },
}),
)
}
for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•')
inhalt.push(
new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 },
}),
)
}
}
inhalt.push(...unterschriftenblock(kopf))
return new Document({
creator: kopf.erstelltVon,
title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true },
),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(' von ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8,
grau: true,
}),
],
}),
],
}),
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
import 'server-only'
import {
AlignmentType,
BorderStyle,
Document,
Footer,
Header,
HeadingLevel,
PageNumber,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/**
* DOCX-Ausgabe.
*
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
*
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung.
*
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/
export type DocxKopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({
text,
bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
})
}
function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
]
if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
)
}
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen
}
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 },
}),
new Paragraph({
children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 },
}),
new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}),
]
}
/**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [
new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 },
}),
...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
),
new Paragraph({
children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 },
}),
]
for (const abschnitt of abschnitte) {
if (abschnitt.titel) {
inhalt.push(
new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 },
}),
)
}
for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•')
inhalt.push(
new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 },
}),
)
}
}
inhalt.push(...unterschriftenblock(kopf))
return new Document({
creator: kopf.erstelltVon,
title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true },
),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(' von ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8,
grau: true,
}),
],
}),
],
}),
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
+4
View File
@@ -122,6 +122,10 @@ export type Zahlungsgruppe = {
const RECHTSFORMEN = [
'gmbh & co kg',
'gmbh und co kg',
// Die haeufigste Rechtsform in diesem Fachgebiet ueberhaupt: Heime,
// Sozialdienste und Traeger sind fast durchweg gGmbH. Fehlte sie,
// zerfiele die Zusammenfassung genau dort, wo sie gebraucht wird.
'ggmbh',
'gmbh',
'mbh',
'ohg',
+214 -214
View File
@@ -1,214 +1,214 @@
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
+10 -4
View File
@@ -1,7 +1,7 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import {
absenderBlock,
anschriftBlock,
@@ -107,7 +107,8 @@ export function betreffAusText(text: string): string {
export type EntwurfEingabe = {
betreuteId: string
benutzerName: string
/** Wer schreibt - mit Anschrift, wenn hinterlegt. */
benutzer: Absender
art: SchreibenArt
vertragId?: string | null
vorgangId?: string | null
@@ -127,7 +128,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName },
benutzer: eingabe.benutzer,
})
const gemeinsam = {
@@ -229,7 +230,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
geburtsdatum: betreute.geburtsdatum,
},
betreuer: {
name: eingabe.benutzerName,
name: eingabe.benutzer.name,
aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht,
},
@@ -278,6 +279,11 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
: null,
betreute_anschrift: kopf.betreuteAnschrift,
betreuer_name: kopf.betreuerName,
betreuer_anschrift:
[kopf.betreuerStrasse, [kopf.betreuerPlz, kopf.betreuerOrt].filter(Boolean).join(' ')]
.filter((zeile) => zeile && zeile.trim() !== '')
.join('\n') || null,
betreuer_ort: kopf.betreuerOrt,
betreuungsgericht: kopf.betreuungsgericht,
aktenzeichen_gericht: kopf.aktenzeichenGericht,
bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null,
+8
View File
@@ -21,6 +21,8 @@ export type PlatzhalterName =
| 'betreute_geburtsdatum'
| 'betreute_anschrift'
| 'betreuer_name'
| 'betreuer_anschrift'
| 'betreuer_ort'
| 'betreuungsgericht'
| 'aktenzeichen_gericht'
| 'bestellung_datum'
@@ -49,6 +51,12 @@ export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
{ name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' },
{ name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' },
{ name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' },
{
name: 'betreuer_anschrift',
beschriftung: 'Deine Anschrift',
quelle: 'Mein Konto',
},
{ name: 'betreuer_ort', beschriftung: 'Dein Ort', quelle: 'Mein Konto' },
{ name: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' },
{ name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' },
{ name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', quelle: 'Stammdaten' },
+237 -237
View File
@@ -1,237 +1,237 @@
/**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
*
* ACHTUNG - EINZIGE STELLE.
* 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
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden.
*
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft.
*/
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01'
/**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein.
*/
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// -----------------------------------------------------------------------------
/**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung).
*
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt.
*/
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1.
*
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
*
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/
export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/**
* Bekleidungspauschale.
*
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine.
*/
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'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.'
// -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz).
*
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
*
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n,
3: 131_900n,
4: 185_500n,
5: 209_600n,
}
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI',
}
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
//
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen.
//
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub)
// -----------------------------------------------------------------------------
/**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
*
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
*
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung.
*/
export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// -----------------------------------------------------------------------------
// Vermögen, § 90 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
* § 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
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre.
*/
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// -----------------------------------------------------------------------------
// Rechtsbehelf
// -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X).
*/
export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
/**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG).
*
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen.
*/
export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %.
*
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* 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).
*/
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 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.
*/
export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
/**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
*
* ACHTUNG - EINZIGE STELLE.
* 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
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden.
*
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft.
*/
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01'
/**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein.
*/
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// -----------------------------------------------------------------------------
/**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung).
*
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt.
*/
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1.
*
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
*
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/
export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/**
* Bekleidungspauschale.
*
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine.
*/
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'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.'
// -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz).
*
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
*
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n,
3: 131_900n,
4: 185_500n,
5: 209_600n,
}
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI',
}
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
//
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen.
//
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub)
// -----------------------------------------------------------------------------
/**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
*
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
*
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung.
*/
export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// -----------------------------------------------------------------------------
// Vermögen, § 90 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
* § 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
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre.
*/
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// -----------------------------------------------------------------------------
// Rechtsbehelf
// -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X).
*/
export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
/**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG).
*
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen.
*/
export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %.
*
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* 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).
*/
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 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.
*/
export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
+437 -437
View File
@@ -1,437 +1,437 @@
import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* 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
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'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.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, '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),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* 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
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'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.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, '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),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
+171 -171
View File
@@ -1,171 +1,171 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'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.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'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.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.9.0'
export const VERSION = '1.10.2'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+19 -10
View File
@@ -3,11 +3,20 @@ import type { Briefkopf } from '@/lib/vertraege/anschreiben'
/**
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
*
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert,
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin.
* Die Anschrift des Betreuers haengt am Benutzer, nicht an der betreuten
* Person: Sie gehoert zu mir und gilt fuer alle Akten. Ist sie nicht
* hinterlegt, bleiben die Zeilen weg - ein Absenderblock mit leeren
* Zeilen sieht schlimmer aus als einer, der nur aus dem Namen besteht.
*/
/** Wer schreibt - Name und, wenn hinterlegt, die eigene Anschrift. */
export type Absender = {
name: string
strasse?: string | null
plz?: string | null
ort?: string | null
}
export type BriefkopfQuelle = {
betreute: {
vorname: string
@@ -22,7 +31,7 @@ export type BriefkopfQuelle = {
aktenzeichenGericht: string | null
bestellungAm: Date | null
}
benutzer: { name: string }
benutzer: Absender
}
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
@@ -37,11 +46,11 @@ export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
return {
betreuerName: quelle.benutzer.name,
betreuerStrasse: null,
betreuerPlz: null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer.
betreuerOrt: null,
betreuerStrasse: quelle.benutzer.strasse ?? null,
betreuerPlz: quelle.benutzer.plz ?? null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der
// des Heims.
betreuerOrt: quelle.benutzer.ort ?? null,
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
betreuteGeburtsdatum: b.geburtsdatum,
betreuteAnschrift: anschrift || null,
+227 -227
View File
@@ -1,227 +1,227 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Kuendigungsfrist-Rechner.
*
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr.
*
* Deshalb rechnet diese Datei drei Daten aus:
*
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer.
*
* Reine Logik, keine Datenbank.
*/
/**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da.
*/
export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/
export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date
}
export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[]
}
const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false,
naechsterTermin: null,
zugangSpaetestensAm: null,
spaetesterAbsendetag: null,
tageBisAbsendetag: null,
vorwarnung: false,
verpasst: false,
hinweise: [],
}
/**
* Berechnet den naechstmoeglichen Kuendigungstermin.
*
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche.
*/
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) {
hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.',
)
}
const fristMonate = frist ?? 0
const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
)
}
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null
if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
}
if (!termin) {
return {
...LEERER_PLAN,
hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
],
}
}
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1
}
const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) {
hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`,
)
} else if (durchlaeufe > 0) {
hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
)
}
hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`,
)
return {
berechenbar: true,
naechsterTermin: termin,
zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst,
hinweise,
}
}
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
}
/**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben.
*/
export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' }
}
const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
}
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
}
/**
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
*
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
* 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
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
* Spanne unter einem halben Jahr, bleibt es beim Standard.
*/
export function laufzeitAusVertrag(
beginnAm: Date | null,
endeAm: Date | null,
): number | null {
if (!beginnAm || !endeAm) return null
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
return jahre >= 1 ? jahre : null
}
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Kuendigungsfrist-Rechner.
*
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr.
*
* Deshalb rechnet diese Datei drei Daten aus:
*
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer.
*
* Reine Logik, keine Datenbank.
*/
/**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da.
*/
export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/
export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date
}
export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[]
}
const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false,
naechsterTermin: null,
zugangSpaetestensAm: null,
spaetesterAbsendetag: null,
tageBisAbsendetag: null,
vorwarnung: false,
verpasst: false,
hinweise: [],
}
/**
* Berechnet den naechstmoeglichen Kuendigungstermin.
*
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche.
*/
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) {
hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.',
)
}
const fristMonate = frist ?? 0
const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
)
}
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null
if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
}
if (!termin) {
return {
...LEERER_PLAN,
hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
],
}
}
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1
}
const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) {
hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`,
)
} else if (durchlaeufe > 0) {
hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
)
}
hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`,
)
return {
berechenbar: true,
naechsterTermin: termin,
zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst,
hinweise,
}
}
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
}
/**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben.
*/
export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' }
}
const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
}
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
}
/**
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
*
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
* 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
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
* Spanne unter einem halben Jahr, bleibt es beim Standard.
*/
export function laufzeitAusVertrag(
beginnAm: Date | null,
endeAm: Date | null,
): number | null {
if (!beginnAm || !endeAm) return null
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
return jahre >= 1 ? jahre : null
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import {
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
rueckkaufswertAnschreiben,
@@ -53,7 +53,7 @@ export async function serienlaufDurchfuehren(eingabe: {
betreuteId: string
vertragIds: string[]
stichtag: Date
benutzer: { id: string; name: string; email: string }
benutzer: { id: string; name: string; email: string } & Absender
}): Promise<SerienErgebnis> {
const heute = heuteAlsTag()
const { betreuteId, benutzer } = eingabe
@@ -65,7 +65,7 @@ export async function serienlaufDurchfuehren(eingabe: {
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: benutzer.name },
benutzer,
})
// Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere.
File diff suppressed because it is too large Load Diff
+18 -51
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { Prisma } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { verweisePruefen } from '@/lib/auth/verweise'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
import { sealen } from '@/lib/crypto/feld'
@@ -325,29 +326,15 @@ export async function buchungSpeichern(
}
const werte = gepruft.data
if (werte.kontoId) {
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
}
// Wie beim Konto: Der Vertrag muss zu dieser Akte gehoeren.
if (werte.vertragId) {
const vertrag = await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
}
if (werte.belegId) {
const beleg = await db.dokument.findFirst({
where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!beleg) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
}
// Alle vier Verweise auf einmal - der Vorgang war bisher der einzige,
// der ungeprueft durchging.
const verweisfehler = await verweisePruefen(betreuteId, {
konto: werte.kontoId,
vertrag: werte.vertragId,
dokument: werte.belegId,
vorgang: werte.vorgangId,
})
if (verweisfehler) return { fehler: verweisfehler }
const felder = {
kontoId: werte.kontoId,
@@ -635,34 +622,14 @@ export async function vermoegenspostenSpeichern(
}
const werte = gepruft.data
if (werte.kontoId) {
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
}
// Dieselbe Pruefung fuer die beiden anderen Verweise. Sie fehlte:
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert, und
// die darf nicht in eine fremde Akte zeigen.
if (werte.vertragId) {
const vertrag = await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
}
if (werte.immobilieId) {
const immobilie = await db.immobilie.findFirst({
where: { id: werte.immobilieId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!immobilie) {
return { fehler: 'Diese Immobilie gehört nicht zu dieser betreuten Person.' }
}
}
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert - sie
// darf nicht in eine fremde Akte zeigen.
const verweisfehler = await verweisePruefen(betreuteId, {
konto: werte.kontoId,
vertrag: werte.vertragId,
immobilie: werte.immobilieId,
})
if (verweisfehler) return { fehler: verweisfehler }
const felder = {
bezeichnung: werte.bezeichnung,
+385 -385
View File
@@ -1,385 +1,385 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { passwortHashen } from '@/lib/crypto/hash'
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import {
benutzerAktivSchema,
benutzerAnlegenSchema,
darfBenutzerVerwalten,
darfFreigabenVergeben,
freigabeAenderung,
freigabenNormalisieren,
freigabenSchema,
letzterAdminGeschuetzt,
rolleAendernSchema,
sitzungenBeendenSchema,
} from '@/lib/validierung/freigaben'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
* wem wann was freigegeben hat.
*/
export type Aktionszustand = {
fehler?: string
felder?: Record<string, string>
erfolg?: string
warnung?: string
}
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler) {
const schluessel = String(problem.path[0] ?? 'formular')
if (!felder[schluessel]) felder[schluessel] = problem.message
}
return felder
}
async function administratorSein(weiter: string) {
const benutzer = await erfordereBenutzer(weiter)
if (!darfBenutzerVerwalten(benutzer.rolle)) {
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
}
await erfordereFrischeAnmeldung(benutzer, weiter)
return benutzer
}
async function anzahlAktiverAdmins(): Promise<number> {
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
}
// -----------------------------------------------------------------------------
// Benutzer
// -----------------------------------------------------------------------------
export async function benutzerAnlegen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAnlegenSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
rolle: formular.get('rolle'),
passwort: formular.get('passwort'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
if (vorhanden) {
return {
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
felder: { email: 'Bereits vergeben.' },
}
}
const neu = await db.user.create({
data: {
name: gelesen.data.name,
email: gelesen.data.email,
rolle: gelesen.data.rolle,
passwortHash: await passwortHashen(gelesen.data.passwort),
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: neu.id,
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg:
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
`werden – ohne ihn kommt niemand an Fachdaten.`,
}
}
export async function rolleAendern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = rolleAendernSchema.safeParse({
userId: formular.get('userId'),
rolle: formular.get('rolle'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: gelesen.data.rolle,
zielBleibtAktiv: ziel.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
await db.user.update({
where: { id: ziel.id },
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'ROLLE_GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
}
}
export async function benutzerAktivSetzen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAktivSchema.safeParse({
userId: formular.get('userId'),
aktiv: formular.get('aktiv') === 'ja',
})
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.id === admin.id && !gelesen.data.aktiv) {
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
}
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: ziel.rolle,
zielBleibtAktiv: gelesen.data.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
await db.user.update({
where: { id: ziel.id },
data: {
aktiv: gelesen.data.aktiv,
// Wer abgeschaltet wird, ist sofort abgemeldet.
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: gelesen.data.aktiv
? `${ziel.name} kann sich wieder anmelden.`
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
}
}
export async function sitzungenBeenden(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
await db.user.update({
where: { id: ziel.id },
data: { sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: {
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
},
})
revalidatePath('/einstellungen/benutzer')
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
}
// -----------------------------------------------------------------------------
// Freigaben je betreuter Person
// -----------------------------------------------------------------------------
export async function freigabenSpeichern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = freigabenSchema.safeParse({
userId: formular.get('userId'),
betreuteId: formular.get('betreuteId'),
rolle: formular.get('rolle'),
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const { userId, betreuteId, rolle } = gelesen.data
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: freigaben },
)
await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: freigaben },
})
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
// weiterbenutzen koennen.
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
}
const beschreibung = [
aenderung.rolleGeaendert
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
: null,
aenderung.hinzugefuegt.length > 0
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
aenderung.entfernt.length > 0
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
]
.filter(Boolean)
.join('; ')
await protokollieren({
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
},
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
const warnung =
aenderung.sensibelHinzugefuegt.length > 0
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
: undefined
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
}
export async function zugangEntziehen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const userId = String(formular.get('userId') ?? '')
const betreuteId = String(formular.get('betreuteId') ?? '')
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
if (userId === benutzer.id) {
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
}
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
await protokollieren({
aktion: 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
}
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { passwortHashen } from '@/lib/crypto/hash'
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import {
benutzerAktivSchema,
benutzerAnlegenSchema,
darfBenutzerVerwalten,
darfFreigabenVergeben,
freigabeAenderung,
freigabenNormalisieren,
freigabenSchema,
letzterAdminGeschuetzt,
rolleAendernSchema,
sitzungenBeendenSchema,
} from '@/lib/validierung/freigaben'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
* wem wann was freigegeben hat.
*/
export type Aktionszustand = {
fehler?: string
felder?: Record<string, string>
erfolg?: string
warnung?: string
}
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler) {
const schluessel = String(problem.path[0] ?? 'formular')
if (!felder[schluessel]) felder[schluessel] = problem.message
}
return felder
}
async function administratorSein(weiter: string) {
const benutzer = await erfordereBenutzer(weiter)
if (!darfBenutzerVerwalten(benutzer.rolle)) {
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
}
await erfordereFrischeAnmeldung(benutzer, weiter)
return benutzer
}
async function anzahlAktiverAdmins(): Promise<number> {
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
}
// -----------------------------------------------------------------------------
// Benutzer
// -----------------------------------------------------------------------------
export async function benutzerAnlegen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAnlegenSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
rolle: formular.get('rolle'),
passwort: formular.get('passwort'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
if (vorhanden) {
return {
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
felder: { email: 'Bereits vergeben.' },
}
}
const neu = await db.user.create({
data: {
name: gelesen.data.name,
email: gelesen.data.email,
rolle: gelesen.data.rolle,
passwortHash: await passwortHashen(gelesen.data.passwort),
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: neu.id,
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg:
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
`werden – ohne ihn kommt niemand an Fachdaten.`,
}
}
export async function rolleAendern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = rolleAendernSchema.safeParse({
userId: formular.get('userId'),
rolle: formular.get('rolle'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: gelesen.data.rolle,
zielBleibtAktiv: ziel.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
await db.user.update({
where: { id: ziel.id },
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'ROLLE_GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
}
}
export async function benutzerAktivSetzen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAktivSchema.safeParse({
userId: formular.get('userId'),
aktiv: formular.get('aktiv') === 'ja',
})
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.id === admin.id && !gelesen.data.aktiv) {
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
}
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: ziel.rolle,
zielBleibtAktiv: gelesen.data.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
await db.user.update({
where: { id: ziel.id },
data: {
aktiv: gelesen.data.aktiv,
// Wer abgeschaltet wird, ist sofort abgemeldet.
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: gelesen.data.aktiv
? `${ziel.name} kann sich wieder anmelden.`
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
}
}
export async function sitzungenBeenden(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
await db.user.update({
where: { id: ziel.id },
data: { sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: {
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
},
})
revalidatePath('/einstellungen/benutzer')
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
}
// -----------------------------------------------------------------------------
// Freigaben je betreuter Person
// -----------------------------------------------------------------------------
export async function freigabenSpeichern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = freigabenSchema.safeParse({
userId: formular.get('userId'),
betreuteId: formular.get('betreuteId'),
rolle: formular.get('rolle'),
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const { userId, betreuteId, rolle } = gelesen.data
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: freigaben },
)
await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: freigaben },
})
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
// weiterbenutzen koennen.
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
}
const beschreibung = [
aenderung.rolleGeaendert
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
: null,
aenderung.hinzugefuegt.length > 0
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
aenderung.entfernt.length > 0
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
]
.filter(Boolean)
.join('; ')
await protokollieren({
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
},
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
const warnung =
aenderung.sensibelHinzugefuegt.length > 0
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
: undefined
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
}
export async function zugangEntziehen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const userId = String(formular.get('userId') ?? '')
const betreuteId = String(formular.get('betreuteId') ?? '')
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
if (userId === benutzer.id) {
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
}
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
await protokollieren({
aktion: 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
}
+80
View File
@@ -1,5 +1,6 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
@@ -102,3 +103,82 @@ export async function passwortAendern(
await aufMindestdauerWarten(begonnen)
return { erfolg: true }
}
/**
* Die eigene Anschrift.
*
* Sie steht im Absenderblock jedes erzeugten Schreibens und liefert den
* Ort fuer die Datumszeile. Angaben sind einzeln freiwillig: Wer nur den
* Ort hinterlegt, bekommt "Bayreuth, den 23.08.2026" und keinen
* Absenderblock mit leeren Zeilen.
*/
const AnschriftSchema = z.object({
strasse: z.string().trim().max(150, 'Bitte höchstens 150 Zeichen.'),
plz: z.string().trim().max(10, 'Bitte höchstens 10 Zeichen.'),
ort: z.string().trim().max(100, 'Bitte höchstens 100 Zeichen.'),
})
export type AnschriftZustand = {
fehler?: string
felder?: Partial<Record<'strasse' | 'plz' | 'ort', string>>
erfolg?: string
}
export async function anschriftSpeichern(
_vorher: AnschriftZustand,
formular: FormData,
): Promise<AnschriftZustand> {
const benutzer = await erfordereBenutzer()
const gelesen = AnschriftSchema.safeParse({
strasse: formular.get('strasse') ?? '',
plz: formular.get('plz') ?? '',
ort: formular.get('ort') ?? '',
})
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
return {
fehler: 'Bitte prüfe die Eingaben.',
felder: {
strasse: felder.strasse?.[0],
plz: felder.plz?.[0],
ort: felder.ort?.[0],
},
}
}
// Leer heisst "nicht hinterlegt", nicht "leere Zeichenkette" - sonst
// stuende im Brief eine Zeile aus Leerzeichen.
const leerZuNull = (wert: string) => (wert === '' ? null : wert)
await db.user.update({
where: { id: benutzer.id },
data: {
strasse: leerZuNull(gelesen.data.strasse),
plz: leerZuNull(gelesen.data.plz),
ort: leerZuNull(gelesen.data.ort),
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: benutzer.id,
bezeichnung: benutzer.name,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
anschrift: {
alt: [benutzer.strasse, benutzer.plz, benutzer.ort].filter(Boolean).join(', ') || null,
neu:
[gelesen.data.strasse, gelesen.data.plz, gelesen.data.ort].filter(Boolean).join(', ') ||
null,
},
},
})
revalidatePath('/konto')
return { erfolg: 'Deine Anschrift ist gespeichert.' }
}
+293 -293
View File
@@ -1,293 +1,293 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, papierkorbGrenze } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { entfernen } from '@/lib/speicher'
import { papierkorbSchema } from '@/lib/validierung/freigaben'
/**
* Papierkorb.
*
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
*
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
* es ihn gab und wer ihn entfernt hat.
*/
export type Aktionszustand = {
fehler?: string
erfolg?: string
}
/**
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
*/
type Eintragsart = {
bezeichnung: string
mehrzahl: string
modul: Modul
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
}
export type PapierkorbEintrag = {
id: string
entitaet: string
bezeichnung: string
geloeschtAm: Date
}
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
entitaet: string,
bezeichnung: string,
mehrzahl: string,
modul: Modul,
zugriff: {
findMany: (args: unknown) => Promise<T[]>
update: (args: unknown) => Promise<unknown>
delete: (args: unknown) => Promise<unknown>
},
titelFeld: string,
): Eintragsart {
return {
bezeichnung,
mehrzahl,
modul,
laden: async (betreuteId, seit) => {
const treffer = await zugriff.findMany({
where: { betreuteId, geloeschtAm: { gte: seit } },
orderBy: { geloeschtAm: 'desc' },
})
return treffer.map((datensatz) => ({
id: datensatz.id,
entitaet,
bezeichnung:
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
geloeschtAm: datensatz.geloeschtAm!,
}))
},
wiederherstellen: async (id, betreuteId) => {
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
},
endgueltigLoeschen: async (id, betreuteId) => {
await zugriff.delete({ where: { id, betreuteId } })
},
}
}
function eintragsarten(): Record<string, Eintragsart> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const z = db as any
return {
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
Vermoegensposten: einfach(
'Vermoegensposten',
'Vermögensposten',
'Vermögensposten',
'FINANZEN',
z.vermoegensposten,
'bezeichnung',
),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach(
'Schreibvorlage',
'Vorlage',
'Vorlagen',
'DOKUMENTE',
z.schreibvorlage,
'name',
),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach(
'Kontaktprotokoll',
'Kontaktprotokoll',
'Kontaktprotokolle',
'KONTAKTE',
z.kontaktprotokoll,
'gespraechspartner',
),
Gesundheitsereignis: einfach(
'Gesundheitsereignis',
'Gesundheitsereignis',
'Gesundheitsereignisse',
'GESUNDHEIT',
z.gesundheitsereignis,
'titel',
),
Genehmigung: einfach(
'Genehmigung',
'Genehmigung',
'Genehmigungen',
'VORGAENGE',
z.genehmigung,
'titel',
),
SozialhilfeAntrag: einfach(
'SozialhilfeAntrag',
'Sozialhilfeantrag',
'Sozialhilfeanträge',
'SOZIALHILFE',
z.sozialhilfeAntrag,
'leistungsart',
),
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
export async function papierkorbLaden(betreuteId: string): Promise<
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
> {
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
const seit = papierkorbGrenze()
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
continue
}
const eintraege = await beschreibung.laden(betreuteId, seit)
if (eintraege.length > 0) {
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
}
}
return gruppen
}
/**
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
*
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/
async function objektSchluesselFinden(
entitaet: string,
entitaetId: string,
betreuteId: string,
): Promise<string | null> {
if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return dokument?.objektSchluessel ?? null
}
if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return bericht?.objektSchluessel ?? null
}
return null
}
export async function wiederherstellen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'WIEDERHERGESTELLT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist wieder da.` }
}
/**
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
*/
export async function endgueltigLoeschen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten.
const schluessel = await objektSchluesselFinden(
gelesen.data.entitaet,
gelesen.data.entitaetId,
gelesen.data.betreuteId,
)
if (schluessel) {
try {
await entfernen(schluessel)
} catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
}
}
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'ENDGUELTIG_GELOESCHT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
}
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, papierkorbGrenze } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { entfernen } from '@/lib/speicher'
import { papierkorbSchema } from '@/lib/validierung/freigaben'
/**
* Papierkorb.
*
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
*
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
* es ihn gab und wer ihn entfernt hat.
*/
export type Aktionszustand = {
fehler?: string
erfolg?: string
}
/**
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
*/
type Eintragsart = {
bezeichnung: string
mehrzahl: string
modul: Modul
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
}
export type PapierkorbEintrag = {
id: string
entitaet: string
bezeichnung: string
geloeschtAm: Date
}
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
entitaet: string,
bezeichnung: string,
mehrzahl: string,
modul: Modul,
zugriff: {
findMany: (args: unknown) => Promise<T[]>
update: (args: unknown) => Promise<unknown>
delete: (args: unknown) => Promise<unknown>
},
titelFeld: string,
): Eintragsart {
return {
bezeichnung,
mehrzahl,
modul,
laden: async (betreuteId, seit) => {
const treffer = await zugriff.findMany({
where: { betreuteId, geloeschtAm: { gte: seit } },
orderBy: { geloeschtAm: 'desc' },
})
return treffer.map((datensatz) => ({
id: datensatz.id,
entitaet,
bezeichnung:
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
geloeschtAm: datensatz.geloeschtAm!,
}))
},
wiederherstellen: async (id, betreuteId) => {
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
},
endgueltigLoeschen: async (id, betreuteId) => {
await zugriff.delete({ where: { id, betreuteId } })
},
}
}
function eintragsarten(): Record<string, Eintragsart> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const z = db as any
return {
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
Vermoegensposten: einfach(
'Vermoegensposten',
'Vermögensposten',
'Vermögensposten',
'FINANZEN',
z.vermoegensposten,
'bezeichnung',
),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach(
'Schreibvorlage',
'Vorlage',
'Vorlagen',
'DOKUMENTE',
z.schreibvorlage,
'name',
),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach(
'Kontaktprotokoll',
'Kontaktprotokoll',
'Kontaktprotokolle',
'KONTAKTE',
z.kontaktprotokoll,
'gespraechspartner',
),
Gesundheitsereignis: einfach(
'Gesundheitsereignis',
'Gesundheitsereignis',
'Gesundheitsereignisse',
'GESUNDHEIT',
z.gesundheitsereignis,
'titel',
),
Genehmigung: einfach(
'Genehmigung',
'Genehmigung',
'Genehmigungen',
'VORGAENGE',
z.genehmigung,
'titel',
),
SozialhilfeAntrag: einfach(
'SozialhilfeAntrag',
'Sozialhilfeantrag',
'Sozialhilfeanträge',
'SOZIALHILFE',
z.sozialhilfeAntrag,
'leistungsart',
),
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
export async function papierkorbLaden(betreuteId: string): Promise<
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
> {
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
const seit = papierkorbGrenze()
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
continue
}
const eintraege = await beschreibung.laden(betreuteId, seit)
if (eintraege.length > 0) {
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
}
}
return gruppen
}
/**
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
*
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/
async function objektSchluesselFinden(
entitaet: string,
entitaetId: string,
betreuteId: string,
): Promise<string | null> {
if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return dokument?.objektSchluessel ?? null
}
if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return bericht?.objektSchluessel ?? null
}
return null
}
export async function wiederherstellen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'WIEDERHERGESTELLT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist wieder da.` }
}
/**
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
*/
export async function endgueltigLoeschen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten.
const schluessel = await objektSchluesselFinden(
gelesen.data.entitaet,
gelesen.data.entitaetId,
gelesen.data.betreuteId,
)
if (schluessel) {
try {
await entfernen(schluessel)
} catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
}
}
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'ENDGUELTIG_GELOESCHT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
}
+14 -32
View File
@@ -6,6 +6,7 @@ import type { Prisma } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang } from '@/lib/auth/sitzung'
import { aenderungErmitteln, protokollieren, type Aenderung } from '@/lib/audit'
import { verweisePruefen } from '@/lib/auth/verweise'
import { oeffnen, sealen } from '@/lib/crypto/feld'
import {
AufgabeAbhakenSchema,
@@ -69,34 +70,6 @@ async function kontaktnameZu(kontaktId: string | null): Promise<string | null> {
return [kontakt.name, kontakt.organisation].filter(Boolean).join(', ')
}
/**
* Gehoert der Ansprechpartner zu dieser Akte?
*
* Ohne die Pruefung koennte ein veraendertes Formular einen Kontakt aus
* einer fremden Akte an den Vorgang haengen - und sein Name stuende
* anschliessend im Briefkopf.
*/
async function ansprechpartnerPruefen(
betreuteId: string,
ansprechpartnerId: string | null,
): Promise<{ ok: true } | { ok: false; zustand: Formularzustand }> {
if (!ansprechpartnerId) return { ok: true }
const kontakt = await db.kontakt.findFirst({
where: { id: ansprechpartnerId, betreuteId, geloeschtAm: null },
select: { id: true },
})
if (kontakt) return { ok: true }
return {
ok: false,
zustand: {
fehler: 'Diesen Kontakt gibt es nicht (mehr).',
felder: { ansprechpartnerId: 'Bitte einen Kontakt aus dem Verzeichnis wählen.' },
},
}
}
export async function vorgangAnlegen(
_vorher: Formularzustand,
formular: FormData,
@@ -119,8 +92,13 @@ export async function vorgangAnlegen(
const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
// Sein Name stuende sonst im Briefkopf - siehe lib/auth/verweise.ts.
const verweisfehler = await verweisePruefen(betreuteId, {
kontakt: eingabe.ansprechpartnerId,
})
if (verweisfehler) {
return { fehler: verweisfehler, felder: { ansprechpartnerId: verweisfehler } }
}
const jetzt = new Date()
@@ -250,8 +228,12 @@ export async function vorgangAendern(
const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(vorgang.betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
const verweisfehler = await verweisePruefen(vorgang.betreuteId, {
kontakt: eingabe.ansprechpartnerId,
})
if (verweisfehler) {
return { fehler: verweisfehler, felder: { ansprechpartnerId: verweisfehler } }
}
const vorherOffen = oeffnen('Vorgang', vorgang)
+665 -665
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { absenderBlock, datumsZeile } from '@/lib/vertraege/anschreiben'
/**
* Der Absenderblock.
*
* Ein foermliches Schreiben ohne Ruecksendeadresse wirkt unserioes und
* macht es der Gegenstelle schwer zu antworten. Umgekehrt gilt: Lieber
* nur der Name als ein Block mit leeren Zeilen - eine halb ausgefuellte
* Adresse sieht nach Fehler aus, ein blosser Name nach Absicht.
*/
const BETREUTE = {
vorname: 'Erika',
nachname: 'Musterfrau',
anrede: 'Frau',
geburtsdatum: new Date(Date.UTC(1943, 3, 17)),
heimName: 'Seniorenheim Sonnenhof',
heimStrasse: 'Heimweg 3',
heimPlz: '95445',
heimOrt: 'Bayreuth',
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 412/25',
bestellungAm: new Date(Date.UTC(2026, 1, 14)),
}
const VOLLSTAENDIG = {
name: 'Florian Wilde',
strasse: 'Cottenbacher Str. 23',
plz: '95445',
ort: 'Bayreuth',
}
describe('Absenderblock', () => {
it('nennt Name, Straße und Ort', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(absenderBlock(kopf)).toBe('Florian Wilde\nCottenbacher Str. 23\n95445 Bayreuth')
})
it('bleibt beim Namen, wenn keine Anschrift hinterlegt ist', () => {
// Kein Block mit leeren Zeilen - das sieht nach Fehler aus.
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: { name: 'Florian Wilde' } })
expect(absenderBlock(kopf)).toBe('Florian Wilde')
})
it('lässt die Straße weg, wenn nur der Ort bekannt ist', () => {
const kopf = briefkopfBauen({
betreute: BETREUTE,
benutzer: { name: 'Florian Wilde', plz: '95445', ort: 'Bayreuth' },
})
expect(absenderBlock(kopf)).toBe('Florian Wilde\n95445 Bayreuth')
})
it('verträgt einen Ort ohne PLZ', () => {
const kopf = briefkopfBauen({
betreute: BETREUTE,
benutzer: { name: 'Florian Wilde', ort: 'Bayreuth' },
})
expect(absenderBlock(kopf)).toBe('Florian Wilde\nBayreuth')
})
it('nimmt die Anschrift vom Betreuer, nicht vom Heim', () => {
// Die Heimadresse ist die der betreuten Person und gehört in den
// Anschriftenblock, nie in den Absender.
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(absenderBlock(kopf)).not.toContain('Heimweg')
expect(kopf.betreuteAnschrift).toContain('Heimweg 3')
})
})
describe('Datumszeile', () => {
const heute = new Date(Date.UTC(2026, 7, 23))
it('stellt den Ort des Betreuers voran', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: VOLLSTAENDIG })
expect(datumsZeile(kopf.betreuerOrt, heute)).toBe('Bayreuth, den 23.08.2026')
})
it('lässt ohne Ort das Komma weg', () => {
const kopf = briefkopfBauen({ betreute: BETREUTE, benutzer: { name: 'Florian Wilde' } })
expect(datumsZeile(kopf.betreuerOrt, heute)).toBe('23.08.2026')
})
})
+607 -607
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import {
dropZeilenEntfernen,
hatAnweisungen,
kopfBauen,
ordnernameBauen,
} from '../scripts/migration-erzeugen.mjs'
/**
* Der Migrations-Wrapper.
*
* Die eine Sache, die hier schiefgehen darf und nicht schiefgehen soll:
* zu viel entfernen. Ein DROP INDEX, das Prisma zu Recht schreibt, muss
* stehen bleiben - sonst repariert das Skript die Suche und zerlegt
* dafuer das Schema.
*/
const GESCHUETZT = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
describe('DROP-Zeilen entfernen', () => {
it('nimmt die geschützte Anweisung samt Überschrift heraus', () => {
const roh = [
'-- DropIndex',
'DROP INDEX "dokument_absender_trgm";',
'',
'-- AlterTable',
'ALTER TABLE "kontakt" ADD COLUMN "anrede" TEXT;',
].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual(['dokument_absender_trgm'])
expect(sql).not.toContain('DROP INDEX')
expect(sql).not.toContain('DropIndex')
expect(sql).toContain('ALTER TABLE "kontakt"')
})
it('lässt ein DROP INDEX auf einen Prisma-eigenen Index stehen', () => {
// Der Kern der Sache: Das Skript darf nicht alles wegräumen, was nach
// DROP INDEX aussieht - manche sollen wirklich weg.
const roh = ['-- DropIndex', 'DROP INDEX "vertrag_betreuteId_status_idx";'].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toContain('DROP INDEX "vertrag_betreuteId_status_idx";')
expect(sql).toContain('-- DropIndex')
})
it('entfernt mehrere geschützte Anweisungen und meldet beide', () => {
const roh = [
'-- DropIndex',
'DROP INDEX "dokument_absender_trgm";',
'',
'-- DropIndex',
'DROP INDEX "vorgang_titel_trgm";',
'',
'-- CreateTable',
'CREATE TABLE "schreibvorlage" ();',
].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual(['dokument_absender_trgm', 'vorgang_titel_trgm'])
expect(sql.startsWith('-- CreateTable')).toBe(true)
})
it('verträgt IF EXISTS und abweichende Schreibweise', () => {
const roh = 'drop index if exists "vorgang_titel_trgm";'
expect(dropZeilenEntfernen(roh, GESCHUETZT).entfernt).toEqual(['vorgang_titel_trgm'])
})
it('lässt eine Migration ohne Treffer unverändert', () => {
const roh = ['-- AlterTable', 'ALTER TABLE "vorgang" ADD COLUMN "x" TEXT;'].join('\n')
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toBe(roh)
})
it('greift nicht bei einem bloßen Vorkommen des Namens im Text', () => {
// Ein Kommentar, der den Index nennt, ist keine Anweisung.
const roh = '-- dokument_absender_trgm bleibt bestehen'
const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT)
expect(entfernt).toEqual([])
expect(sql).toBe(roh)
})
})
describe('Leere Migration erkennen', () => {
it('sieht eine echte Anweisung', () => {
expect(hatAnweisungen('-- AlterTable\nALTER TABLE "x" ADD COLUMN "y" TEXT;')).toBe(true)
})
it('hält reinen Kommentartext für leer', () => {
// Der Normalfall: Schema und Datenbank stimmen überein, der ganze
// Diff bestand aus den zwei bekannten DROP-Zeilen. Dann darf keine
// Migration entstehen — eine leere in der Historie ist schlimmer als
// keine.
expect(hatAnweisungen('-- nur ein Kommentar\n\n-- noch einer')).toBe(false)
})
it('hält Leerraum für leer', () => {
expect(hatAnweisungen(' \n\n ')).toBe(false)
})
})
describe('Kopfkommentar', () => {
it('benennt die entfernten Indizes und ihre Quelle', () => {
const kopf = kopfBauen('ansprechpartner', ['vorgang_titel_trgm'], GESCHUETZT)
expect(kopf).toContain('vorgang_titel_trgm')
expect(kopf).toContain('prisma/sql/volltextsuche.sql')
})
it('sagt auch, wenn nichts zu entfernen war', () => {
expect(kopfBauen('leer', [], GESCHUETZT)).toContain('Nichts zu entfernen')
})
})
describe('Ordnername', () => {
it('baut den Zeitstempel im Prisma-Format', () => {
expect(ordnernameBauen('anrede', new Date(2026, 7, 23, 9, 5, 3))).toBe(
'20260823090503_anrede',
)
})
})
+47
View File
@@ -85,3 +85,50 @@ describe.skipIf(!mitGit)('Betriebsskripte', () => {
}
})
})
/**
* Abschneidende Leitungen in Skripten mit "pipefail".
*
* Anlass ist ein Abbruch im Betrieb: Beim Sprung von v1.5.0 auf v1.10.1
* endete update.sh mitten in der Ausgabe, ohne jede Meldung. Ursache war
*
* git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
*
* "head" schliesst die Leitung nach 15 Zeilen, "git log" schreibt weiter
* und bekommt SIGPIPE (Exitcode 141). Mit "set -o pipefail" gilt die
* ganze Kette als gescheitert, "set -e" beendet das Skript - stumm.
*
* Es ist zeitabhaengig: Solange der Erzeuger fertig wird, bevor "head"
* zumacht, faellt nichts auf. Deshalb lief es monatelang gut und riss
* genau dann, als zum ersten Mal mehr als 15 Commits dazwischenlagen.
*
* Dieselbe Falle steckte in der Selbstpruefung der Sicherung, wo sie
* eine intakte Sicherung als kaputt gemeldet haette, sobald die
* Datenbank gross genug ist.
*
* Der Test verbietet das Muster deshalb ganz. Wer eine Ausgabe kuerzen
* will, nimmt "git log -15", "sed -n '1,15p'" oder einen Zaehler in awk -
* alles drei liest bis zum Ende und schliesst nichts zu frueh.
*/
describe('Skripte mit pipefail', () => {
const skripte = ['scripts/update.sh', 'scripts/sicherung.sh', 'scripts/wiederherstellung.sh']
it.each(skripte)('%s schneidet keine Leitung mit head ab', (pfad) => {
const inhalt = readFileSync(pfad, 'utf8')
if (!/set -[a-z]*o pipefail|set -o pipefail/.test(inhalt)) return
const treffer = inhalt
.split('\n')
.map((zeile, nummer) => ({ zeile, nummer: nummer + 1 }))
// Kommentarzeilen erklaeren das Muster gerade - sie sind erlaubt.
.filter(({ zeile }) => !zeile.trim().startsWith('#'))
.filter(({ zeile }) => !zeile.includes('printf'))
.filter(({ zeile }) => /\|\s*head\b/.test(zeile))
expect(
treffer.map((t) => `Zeile ${t.nummer}: ${t.zeile.trim()}`),
'Abschneidende Leitung: der Erzeuger bekommt SIGPIPE, pipefail reicht ' +
'es weiter, set -e beendet das Skript stumm.',
).toEqual([])
})
})
+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 { tag } from '@/lib/fristen/datum'
import {
absendetagZu,
kuendigungsampel,
kuendigungsplan,
KUENDIGUNG_VORWARNUNG_TAGE,
laufzeitAusVertrag,
POSTLAUF_PUFFER_TAGE,
} from '@/lib/vertraege/kuendigung'
import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent'
import {
anfrageStatus,
juengsterWert,
rueckkaufswertAnschreiben,
wertHistorie,
wiedervorlageAm,
} from '@/lib/vertraege/rueckkaufswert'
import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
const kopf = {
betreuerName: 'Florian Wilde',
betreuerStrasse: 'Musterweg 1',
betreuerPlz: '95444',
betreuerOrt: 'Bayreuth',
betreuteName: 'Erika Wilde',
betreuteGeburtsdatum: tag(1948, 5, 20),
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 123/25',
bestellungAm: tag(2025, 11, 3),
}
describe('Kündigungstermin', () => {
it('rechnet den nächstmöglichen Termin über die Jahresgrenze', () => {
// 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 -
// der Absendetag ist Anfang Februar 2026 längst vorbei.
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.berechenbar).toBe(true)
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 12, 15))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 12, 12))
expect(plan.verpasst).toBe(false)
})
it('rechnet über die Monatsgrenze kalendarisch, nicht in Tagen', () => {
// 31.05. minus drei Monate ist der 28.02. (2026 ist kein Schaltjahr),
// nicht der 02.03. oder 03.03.
const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 1, 5),
})
expect(plan.naechsterTermin).toEqual(tag(2026, 5, 31))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 2, 28))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 2, 25))
})
it('trifft im Schaltjahr den 29. Februar', () => {
const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2024, 1, 5),
})
expect(plan.zugangSpaetestensAm).toEqual(tag(2024, 2, 29))
})
it('springt bei mehrjähriger Laufzeit um die volle Laufzeit weiter', () => {
const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1),
laufzeitJahre: 3,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
// Termine: 2021, 2024, 2027 - für 2027 muss bis 30.09.2026 zugehen.
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 10, 1))
})
it('rechnet den Postlauf zwischen Absenden und Zugang ein', () => {
expect(absendetagZu(tag(2026, 6, 30), 3)).toEqual(tag(2026, 3, 27))
expect(POSTLAUF_PUFFER_TAGE).toBe(3)
})
it('warnt, wenn der Absendetag näher als 30 Tage rückt', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 3, 20),
})
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 3, 27))
expect(plan.tageBisAbsendetag).toBe(7)
expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot')
})
it('warnt genau an der Grenze von 30 Tagen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 25),
})
expect(plan.tageBisAbsendetag).toBe(KUENDIGUNG_VORWARNUNG_TAGE)
expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('orange')
})
it('warnt einen Tag vor der Grenze noch nicht', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 24),
})
expect(plan.tageBisAbsendetag).toBe(31)
expect(plan.vorwarnung).toBe(false)
})
it('nimmt ohne hinterlegte Laufzeit ein Jahr an und sagt das dazu', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
laufzeitJahre: null,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.hinweise.some((h) => h.includes('keine Laufzeit'))).toBe(true)
})
it('meldet einen verpassten Termin, wenn sich der Vertrag nicht verlängert', () => {
const plan = kuendigungsplan({
beginnAm: tag(2010, 1, 1),
endeAm: tag(2026, 3, 1),
kuendigungsfristMonate: 3,
verlaengertSichAutomatisch: false,
heute: tag(2026, 2, 1),
})
expect(plan.verpasst).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot')
})
it('rechnet gar nicht, wenn weder Beginn noch Ende bekannt sind', () => {
const plan = kuendigungsplan({
beginnAm: null,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.berechenbar).toBe(false)
expect(plan.naechsterTermin).toBeNull()
expect(kuendigungsampel(plan)).toBeNull()
})
it('weist auf die fehlende Kündigungsfrist hin, statt still mit null zu rechnen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
kuendigungsfristMonate: null,
heute: tag(2026, 2, 1),
})
expect(plan.hinweise.some((h) => h.includes('keine Kündigungsfrist'))).toBe(true)
})
})
describe('Rückkaufswert-Anforderung', () => {
it('legt die Wiedervorlage vier Wochen nach der Anfrage', () => {
expect(wiedervorlageAm(tag(2026, 3, 2))).toEqual(tag(2026, 3, 30))
})
it('fragt Rückkaufswert und beitragsfreie Versicherungssumme zusammen ab', () => {
const brief = rueckkaufswertAnschreiben({
kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: 'Postfach 1\n80000 München' },
vertragsbezeichnung: 'Sterbegeldversicherung',
vertragsnummer: 'LV-4711',
stichtag: tag(2026, 1, 1),
heute: tag(2026, 3, 2),
})
expect(brief).toContain('LV-4711')
expect(brief).toContain('Rückkaufswert')
expect(brief).toContain('beitragsfreie Versicherungssumme')
expect(brief).toContain('Amtsgericht Bayreuth')
expect(brief).toContain('XVII 123/25')
expect(brief).toContain('01.01.2026')
})
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, 30)).erinnerungFaellig).toBe(true)
expect(anfrageStatus(null, tag(2026, 3, 30)).offen).toBe(false)
})
})
describe('Kündigungs-Assistent', () => {
const eingabe = {
kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: null },
vertragsbezeichnung: 'Kapitallebensversicherung',
vertragsnummer: 'LV-4711',
art: 'LEBENSVERSICHERUNG' as const,
laufzeit: {
beginnAm: tag(2000, 4, 1),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
},
heute: tag(2026, 2, 1),
}
it('erzeugt ein Schreiben mit Vertragsnummer und Bestellungsnachweis', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.schreiben).toContain('LV-4711')
expect(paket.schreiben).toContain('kündige ich den oben genannten Vertrag')
expect(paket.schreiben).toContain('rechtlichen Betreuer')
expect(paket.schreiben).toContain('hilfsweise zum nächstmöglichen Zeitpunkt')
})
it('stellt bei einer Lebensversicherung die Genehmigungsfrage, ohne sie zu beantworten', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.genehmigung.stellen).toBe(true)
expect(paket.genehmigung.rechtsgrundlage).toBe('§ 1850 BGB')
expect(paket.genehmigung.frage).toMatch(/Rechtspfleger/)
expect(paket.genehmigung.hinweis).toMatch(/entscheidet das ausdrücklich nicht/)
})
it('stellt bei einer Hausratversicherung keine Genehmigungsfrage', () => {
expect(genehmigungsfrage('HAUSRAT').stellen).toBe(false)
expect(genehmigungsfrage('LEBENSVERSICHERUNG').stellen).toBe(true)
})
it('verlangt immer einen Versandnachweis', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.versandnachweis.verlangt).toBe(true)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Einschreiben/)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Fax/)
})
it('erinnert vor der Kündigung an die Beitragsfreistellung als Alternative', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.warnungen.some((w) => w.includes('beitragsfreie Versicherungssumme'))).toBe(true)
})
it('warnt bei einer Bestattungsvorsorge vor der vorschnellen Kündigung', () => {
const paket = kuendigungsassistent({ ...eingabe, art: 'BESTATTUNGSVORSORGE' })
expect(paket.warnungen.some((w) => w.includes('§ 90 Abs. 3 SGB XII'))).toBe(true)
})
it('warnt, wenn die Vertragsnummer fehlt', () => {
const paket = kuendigungsassistent({ ...eingabe, vertragsnummer: null })
expect(paket.warnungen.some((w) => w.includes('Vertragsnummer fehlt'))).toBe(true)
})
})
describe('Beiträge und Vertragsarten', () => {
it('rechnet den Jahresbeitrag aus Beitrag und Rhythmus', () => {
expect(jahresbeitragCent(2_500n, 'MONATLICH')).toBe(30_000n)
expect(jahresbeitragCent(2_500n, 'VIERTELJAEHRLICH')).toBe(10_000n)
expect(jahresbeitragCent(2_500n, 'JAEHRLICH')).toBe(2_500n)
expect(jahresbeitragCent(2_500n, 'EINMALIG')).toBeNull()
expect(jahresbeitragCent(null, 'MONATLICH')).toBeNull()
})
it('kennt die Verträge, bei denen ein Rückkaufswert überhaupt anfällt', () => {
expect(kannRueckkaufswertHaben('LEBENSVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STERBEGELDVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STROM')).toBe(false)
})
})
describe('Vertragsdauer aus Beginn und Ende', () => {
it('leitet die Laufzeit aus einem festen Vertragsende ab', () => {
expect(laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1))).toBe(3)
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', () => {
expect(laufzeitAusVertrag(tag(2020, 1, 1), null)).toBeNull()
expect(laufzeitAusVertrag(null, tag(2021, 1, 1))).toBeNull()
expect(laufzeitAusVertrag(tag(2020, 1, 1), tag(2020, 7, 1))).toBeNull()
})
it('lässt einen dreijährigen Vertrag um drei Jahre weiterspringen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1),
endeAm: tag(2021, 1, 1),
laufzeitJahre: laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1)),
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
})
})
describe('Rückkaufswert-Historie am Vertrag', () => {
const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent })
it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => {
// Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim
// ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine.
const historie = wertHistorie([
{ wertstaende: [wert(2024, 4000)] },
{ wertstaende: [wert(2026, 5200), wert(2025, 4600)] },
])
expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000])
})
it('nennt den zuletzt mitgeteilten Wert', () => {
expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe(
5200,
)
})
it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => {
expect(juengsterWert([{ wertstaende: [] }])).toBeNull()
expect(juengsterWert([])).toBeNull()
})
})
import { describe, expect, it } from 'vitest'
import { tag } from '@/lib/fristen/datum'
import {
absendetagZu,
kuendigungsampel,
kuendigungsplan,
KUENDIGUNG_VORWARNUNG_TAGE,
laufzeitAusVertrag,
POSTLAUF_PUFFER_TAGE,
} from '@/lib/vertraege/kuendigung'
import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent'
import {
anfrageStatus,
juengsterWert,
rueckkaufswertAnschreiben,
wertHistorie,
wiedervorlageAm,
} from '@/lib/vertraege/rueckkaufswert'
import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
const kopf = {
betreuerName: 'Florian Wilde',
betreuerStrasse: 'Musterweg 1',
betreuerPlz: '95444',
betreuerOrt: 'Bayreuth',
betreuteName: 'Erika Wilde',
betreuteGeburtsdatum: tag(1948, 5, 20),
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 123/25',
bestellungAm: tag(2025, 11, 3),
}
describe('Kündigungstermin', () => {
it('rechnet den nächstmöglichen Termin über die Jahresgrenze', () => {
// 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 -
// der Absendetag ist Anfang Februar 2026 längst vorbei.
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.berechenbar).toBe(true)
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 12, 15))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 12, 12))
expect(plan.verpasst).toBe(false)
})
it('rechnet über die Monatsgrenze kalendarisch, nicht in Tagen', () => {
// 31.05. minus drei Monate ist der 28.02. (2026 ist kein Schaltjahr),
// nicht der 02.03. oder 03.03.
const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 1, 5),
})
expect(plan.naechsterTermin).toEqual(tag(2026, 5, 31))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 2, 28))
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 2, 25))
})
it('trifft im Schaltjahr den 29. Februar', () => {
const plan = kuendigungsplan({
beginnAm: tag(2019, 5, 31),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2024, 1, 5),
})
expect(plan.zugangSpaetestensAm).toEqual(tag(2024, 2, 29))
})
it('springt bei mehrjähriger Laufzeit um die volle Laufzeit weiter', () => {
const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1),
laufzeitJahre: 3,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
// Termine: 2021, 2024, 2027 - für 2027 muss bis 30.09.2026 zugehen.
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 10, 1))
})
it('rechnet den Postlauf zwischen Absenden und Zugang ein', () => {
expect(absendetagZu(tag(2026, 6, 30), 3)).toEqual(tag(2026, 3, 27))
expect(POSTLAUF_PUFFER_TAGE).toBe(3)
})
it('warnt, wenn der Absendetag näher als 30 Tage rückt', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 3, 20),
})
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 3, 27))
expect(plan.tageBisAbsendetag).toBe(7)
expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot')
})
it('warnt genau an der Grenze von 30 Tagen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 25),
})
expect(plan.tageBisAbsendetag).toBe(KUENDIGUNG_VORWARNUNG_TAGE)
expect(plan.vorwarnung).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('orange')
})
it('warnt einen Tag vor der Grenze noch nicht', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 6, 30),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 24),
})
expect(plan.tageBisAbsendetag).toBe(31)
expect(plan.vorwarnung).toBe(false)
})
it('nimmt ohne hinterlegte Laufzeit ein Jahr an und sagt das dazu', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
laufzeitJahre: null,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
expect(plan.hinweise.some((h) => h.includes('keine Laufzeit'))).toBe(true)
})
it('meldet einen verpassten Termin, wenn sich der Vertrag nicht verlängert', () => {
const plan = kuendigungsplan({
beginnAm: tag(2010, 1, 1),
endeAm: tag(2026, 3, 1),
kuendigungsfristMonate: 3,
verlaengertSichAutomatisch: false,
heute: tag(2026, 2, 1),
})
expect(plan.verpasst).toBe(true)
expect(kuendigungsampel(plan)?.variante).toBe('rot')
})
it('rechnet gar nicht, wenn weder Beginn noch Ende bekannt sind', () => {
const plan = kuendigungsplan({
beginnAm: null,
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.berechenbar).toBe(false)
expect(plan.naechsterTermin).toBeNull()
expect(kuendigungsampel(plan)).toBeNull()
})
it('weist auf die fehlende Kündigungsfrist hin, statt still mit null zu rechnen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2020, 3, 15),
kuendigungsfristMonate: null,
heute: tag(2026, 2, 1),
})
expect(plan.hinweise.some((h) => h.includes('keine Kündigungsfrist'))).toBe(true)
})
})
describe('Rückkaufswert-Anforderung', () => {
it('legt die Wiedervorlage vier Wochen nach der Anfrage', () => {
expect(wiedervorlageAm(tag(2026, 3, 2))).toEqual(tag(2026, 3, 30))
})
it('fragt Rückkaufswert und beitragsfreie Versicherungssumme zusammen ab', () => {
const brief = rueckkaufswertAnschreiben({
kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: 'Postfach 1\n80000 München' },
vertragsbezeichnung: 'Sterbegeldversicherung',
vertragsnummer: 'LV-4711',
stichtag: tag(2026, 1, 1),
heute: tag(2026, 3, 2),
})
expect(brief).toContain('LV-4711')
expect(brief).toContain('Rückkaufswert')
expect(brief).toContain('beitragsfreie Versicherungssumme')
expect(brief).toContain('Amtsgericht Bayreuth')
expect(brief).toContain('XVII 123/25')
expect(brief).toContain('01.01.2026')
})
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, 30)).erinnerungFaellig).toBe(true)
expect(anfrageStatus(null, tag(2026, 3, 30)).offen).toBe(false)
})
})
describe('Kündigungs-Assistent', () => {
const eingabe = {
kopf,
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: null },
vertragsbezeichnung: 'Kapitallebensversicherung',
vertragsnummer: 'LV-4711',
art: 'LEBENSVERSICHERUNG' as const,
laufzeit: {
beginnAm: tag(2000, 4, 1),
laufzeitJahre: 1,
kuendigungsfristMonate: 3,
},
heute: tag(2026, 2, 1),
}
it('erzeugt ein Schreiben mit Vertragsnummer und Bestellungsnachweis', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.schreiben).toContain('LV-4711')
expect(paket.schreiben).toContain('kündige ich den oben genannten Vertrag')
expect(paket.schreiben).toContain('rechtlichen Betreuer')
expect(paket.schreiben).toContain('hilfsweise zum nächstmöglichen Zeitpunkt')
})
it('stellt bei einer Lebensversicherung die Genehmigungsfrage, ohne sie zu beantworten', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.genehmigung.stellen).toBe(true)
expect(paket.genehmigung.rechtsgrundlage).toBe('§ 1850 BGB')
expect(paket.genehmigung.frage).toMatch(/Rechtspfleger/)
expect(paket.genehmigung.hinweis).toMatch(/entscheidet das ausdrücklich nicht/)
})
it('stellt bei einer Hausratversicherung keine Genehmigungsfrage', () => {
expect(genehmigungsfrage('HAUSRAT').stellen).toBe(false)
expect(genehmigungsfrage('LEBENSVERSICHERUNG').stellen).toBe(true)
})
it('verlangt immer einen Versandnachweis', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.versandnachweis.verlangt).toBe(true)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Einschreiben/)
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Fax/)
})
it('erinnert vor der Kündigung an die Beitragsfreistellung als Alternative', () => {
const paket = kuendigungsassistent(eingabe)
expect(paket.warnungen.some((w) => w.includes('beitragsfreie Versicherungssumme'))).toBe(true)
})
it('warnt bei einer Bestattungsvorsorge vor der vorschnellen Kündigung', () => {
const paket = kuendigungsassistent({ ...eingabe, art: 'BESTATTUNGSVORSORGE' })
expect(paket.warnungen.some((w) => w.includes('§ 90 Abs. 3 SGB XII'))).toBe(true)
})
it('warnt, wenn die Vertragsnummer fehlt', () => {
const paket = kuendigungsassistent({ ...eingabe, vertragsnummer: null })
expect(paket.warnungen.some((w) => w.includes('Vertragsnummer fehlt'))).toBe(true)
})
})
describe('Beiträge und Vertragsarten', () => {
it('rechnet den Jahresbeitrag aus Beitrag und Rhythmus', () => {
expect(jahresbeitragCent(2_500n, 'MONATLICH')).toBe(30_000n)
expect(jahresbeitragCent(2_500n, 'VIERTELJAEHRLICH')).toBe(10_000n)
expect(jahresbeitragCent(2_500n, 'JAEHRLICH')).toBe(2_500n)
expect(jahresbeitragCent(2_500n, 'EINMALIG')).toBeNull()
expect(jahresbeitragCent(null, 'MONATLICH')).toBeNull()
})
it('kennt die Verträge, bei denen ein Rückkaufswert überhaupt anfällt', () => {
expect(kannRueckkaufswertHaben('LEBENSVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STERBEGELDVERSICHERUNG')).toBe(true)
expect(kannRueckkaufswertHaben('STROM')).toBe(false)
})
})
describe('Vertragsdauer aus Beginn und Ende', () => {
it('leitet die Laufzeit aus einem festen Vertragsende ab', () => {
expect(laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1))).toBe(3)
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', () => {
expect(laufzeitAusVertrag(tag(2020, 1, 1), null)).toBeNull()
expect(laufzeitAusVertrag(null, tag(2021, 1, 1))).toBeNull()
expect(laufzeitAusVertrag(tag(2020, 1, 1), tag(2020, 7, 1))).toBeNull()
})
it('lässt einen dreijährigen Vertrag um drei Jahre weiterspringen', () => {
const plan = kuendigungsplan({
beginnAm: tag(2018, 1, 1),
endeAm: tag(2021, 1, 1),
laufzeitJahre: laufzeitAusVertrag(tag(2018, 1, 1), tag(2021, 1, 1)),
kuendigungsfristMonate: 3,
heute: tag(2026, 2, 1),
})
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
})
})
describe('Rückkaufswert-Historie am Vertrag', () => {
const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent })
it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => {
// Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim
// ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine.
const historie = wertHistorie([
{ wertstaende: [wert(2024, 4000)] },
{ wertstaende: [wert(2026, 5200), wert(2025, 4600)] },
])
expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000])
})
it('nennt den zuletzt mitgeteilten Wert', () => {
expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe(
5200,
)
})
it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => {
expect(juengsterWert([{ wertstaende: [] }])).toBeNull()
expect(juengsterWert([])).toBeNull()
})
})
+11
View File
@@ -75,6 +75,17 @@ describe('Schreibweisen vergleichbar machen', () => {
expect(normalisieren('Debeka AG')).toBe(normalisieren('Debeka'))
})
it('kennt die gGmbH', () => {
// Heime, Sozialdienste und Träger sind fast durchweg gGmbH — ohne
// sie zerfiele die Zusammenfassung genau dort, wo sie zählt.
expect(normalisieren('Seniorenzentrum St. Katharina gGmbH')).toBe(
normalisieren('Seniorenzentrum St. Katharina'),
)
expect(normalisieren('SENIORENZENTRUM ST KATHARINA GGMBH')).toBe(
normalisieren('Seniorenzentrum St. Katharina'),
)
})
it('entfernt auch mehrteilige Rechtsformen', () => {
expect(normalisieren('Stadtwerke Bamberg GmbH & Co KG')).toBe(
normalisieren('Stadtwerke Bamberg'),