Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece995bb6a | ||
|
|
107406e3c0 | ||
|
|
9ea78a4d29 | ||
|
|
1ed19eb76e | ||
|
|
6a0db51e3b | ||
|
|
851724774a | ||
|
|
3772e360e1 | ||
|
|
d75eb185d5 | ||
|
|
91a9aca0d8 | ||
|
|
91592cbe88 | ||
|
|
18709a8e49 | ||
|
|
b6b579a04d | ||
|
|
848aaf1b9f | ||
|
|
5022a97a6f | ||
|
|
110b11bef8 | ||
|
|
4aed9824d1 | ||
|
|
e19300b33d | ||
|
|
41e33ad85f | ||
|
|
3153e6179d | ||
|
|
98d1338eae | ||
|
|
9e68d10787 | ||
|
|
5e7d88cf64 | ||
|
|
09a4528c4b | ||
|
|
57ce73dfbc | ||
|
|
f8d7752c27 | ||
|
|
0a8878d8d2 | ||
|
|
9fe6f10f25 | ||
|
|
96a9bc4d63 | ||
|
|
b59d11c9b4 | ||
|
|
a52523fc97 | ||
|
|
c21d03abf6 |
@@ -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
|
||||||
+481
-1
@@ -26,6 +26,477 @@ Wiederherstellung*.
|
|||||||
|
|
||||||
_Noch nichts._
|
_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
|
||||||
|
|
||||||
|
- **Ansprechpartner am Vorgang**
|
||||||
|
([#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9)).
|
||||||
|
Ein namentlich adressiertes Schreiben landet bei der Person, die den
|
||||||
|
Vorgang kennt, statt in der Poststelle — beim Bezirk ist das der
|
||||||
|
Unterschied zwischen zwei Wochen und zwei Monaten. Am Vorgang lässt
|
||||||
|
sich jetzt ein Kontakt aus dem Verzeichnis als Ansprechpartner
|
||||||
|
benennen.
|
||||||
|
|
||||||
|
- **Die Sachstandsanfrage redet ihn an.** Aus „Sehr geehrte Damen und
|
||||||
|
Herren" wird „Sehr geehrte Frau Sachs" — und der Name steht über der
|
||||||
|
Behörde, damit die Poststelle den Brief zur Person sortiert. Auch das
|
||||||
|
Schreiben aus dem Editor übernimmt Anrede, Empfänger und Anschrift,
|
||||||
|
sodass nichts abgetippt werden muss.
|
||||||
|
|
||||||
|
- **Anrede am Kontakt.** Damit die Briefanrede stimmt, trägt ein Kontakt
|
||||||
|
wahlweise „Frau" oder „Herr". **Geraten wird nie:** Ohne Angabe wird
|
||||||
|
mit vollem Namen angeredet („Sehr geehrte(r) Kim Berger,"). Eine
|
||||||
|
falsch geratene Anrede fällt dem Empfänger sofort auf und kostet genau
|
||||||
|
die Aufmerksamkeit, die man gewinnen wollte.
|
||||||
|
|
||||||
|
- **Vor dem Anruf weiß man, wen man fragt.** Auf der Vorgangsseite steht
|
||||||
|
der Ansprechpartner mit Organisation, Durchwahl und E-Mail, verlinkt
|
||||||
|
auf den Kontakt.
|
||||||
|
|
||||||
|
### Zwei Zusagen
|
||||||
|
|
||||||
|
**Ohne Ansprechpartner ändert sich nichts.** Ist keiner benannt, bleibt
|
||||||
|
es bei „Sehr geehrte Damen und Herren" — und die Vorgangsseite sagt das
|
||||||
|
auch, statt ein leeres Feld zu zeigen. Ein halb gefüllter Name führt nie
|
||||||
|
zu einer verstümmelten Anrede wie „Sehr geehrte Frau ,".
|
||||||
|
|
||||||
|
**Der Wechsel bleibt nachvollziehbar.** Wer den Ansprechpartner
|
||||||
|
austauscht, findet den Vorgänger im Protokoll — mit **Namen**, nicht mit
|
||||||
|
der internen Kennung. Zwei Kennungen nebeneinander wären protokolliert,
|
||||||
|
aber nicht nachvollziehbar.
|
||||||
|
|
||||||
|
### Datenbank
|
||||||
|
|
||||||
|
Eine Migration: `ansprechpartnerId` am Vorgang und `anrede` am Kontakt.
|
||||||
|
Wird ein Kontakt gelöscht, bleibt der Vorgang bestehen und verliert nur
|
||||||
|
den Verweis — ein Vorgang darf nicht daran hängen, dass es den Kontakt
|
||||||
|
noch gibt.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Neun Einheitstests für die Anrede, darunter der Nachname aus „Dr. Anna
|
||||||
|
Maria Schmitt", der Doppelname und der Fall, der nie passieren darf:
|
||||||
|
leerer Name mit gesetzter Anrede.
|
||||||
|
- Fünf End-to-End-Tests: die allgemeine Anrede ohne Ansprechpartner, das
|
||||||
|
Benennen samt Anzeige mit Durchwahl, die namentliche Anrede in der
|
||||||
|
Sachstandsanfrage, die vorbelegte Anschrift im Schreiben und der
|
||||||
|
Wechsel im Protokoll.
|
||||||
|
|
||||||
|
## [1.8.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Der Rückkaufswert steht jetzt beim Vertrag**
|
||||||
|
([#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8)).
|
||||||
|
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
|
||||||
|
gefragt hat — nicht nach einem Umweg über das Vermögensverzeichnis.
|
||||||
|
Auf der Vertragsseite steht deshalb oben der zuletzt mitgeteilte Wert
|
||||||
|
mit Stichtag und Quelle, darunter die vollständige Historie.
|
||||||
|
|
||||||
|
- **Ein alter Wert sagt, dass er alt ist.** Ist der jüngste Wert älter
|
||||||
|
als ein Jahr, steht „über ein Jahr alt" daneben — in der
|
||||||
|
Vertragsübersicht wie auf der Vertragsseite. Ein veralteter Betrag
|
||||||
|
ohne erkennbares Alter ist schlimmer als kein Betrag: Man rechnet mit
|
||||||
|
ihm weiter und merkt es erst, wenn der Bezirk nachfragt. Die Grenze von
|
||||||
|
einem Jahr gilt jetzt überall dieselbe; im Vermögensverzeichnis stand
|
||||||
|
sie schon, am Vertrag fehlte sie.
|
||||||
|
|
||||||
|
- **Verträge ohne Wert bleiben nicht stumm.** Wo ein Rückkaufswert zu
|
||||||
|
erwarten wäre, aber keiner erfasst ist, steht „noch nicht erfasst"
|
||||||
|
statt einer leeren Zelle.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Ein Vertrag hatte unter Umständen zwei Wertreihen.** Vertrag und
|
||||||
|
Vermögensposten wurden über die *Art* des Postens zusammengeführt.
|
||||||
|
Wer einen Posten von Hand anlegte und ihn als etwas anderes als
|
||||||
|
„Versicherung" führte, bekam beim ersten Eintragen am Vertrag
|
||||||
|
klammheimlich einen zweiten Posten — und damit zwei Wahrheiten, von
|
||||||
|
denen der Bericht die eine und der Vertrag die andere zeigte.
|
||||||
|
Maßgeblich ist jetzt die Verknüpfung mit dem Vertrag, nicht die Art.
|
||||||
|
|
||||||
|
- **Fehlende Prüfung bei Verweisen auf Vertrag und Immobilie.** Beim
|
||||||
|
Speichern eines Vermögenspostens wurde geprüft, ob das verknüpfte
|
||||||
|
*Konto* zur betreuten Person gehört — bei Vertrag und Immobilie fehlte
|
||||||
|
dieselbe Prüfung, ebenso beim Vertragsverweis einer Buchung. Bei
|
||||||
|
mehreren betreuten Personen hätte ein manipuliertes Formular eine
|
||||||
|
Verknüpfung über Aktengrenzen hinweg anlegen können. Jetzt wird jeder
|
||||||
|
der drei Verweise gleich behandelt.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Zehn Einheitstests: die Ein-Jahres-Grenze (auch der Tag davor und
|
||||||
|
danach), das Alter taggenau und ohne Uhrzeiteinfluss, ein künftiger
|
||||||
|
Stichtag, und die Zusammenführung mehrerer Posten zu einer Historie.
|
||||||
|
- Vier End-to-End-Tests: Wert und Stichtag in der Übersicht, das
|
||||||
|
Abzeichen am alten Wert, die Historie am Vertrag, und ein neu
|
||||||
|
eingetragener Wert, der unverändert im Vermögensverzeichnis
|
||||||
|
wiederauftaucht.
|
||||||
|
|
||||||
|
## [1.7.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Eigene Vorlagen für Anschreiben**
|
||||||
|
([#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7)).
|
||||||
|
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
|
||||||
|
falsch. Neu unter **Einstellungen → Schreibvorlagen**: eigene Texte
|
||||||
|
hinterlegen, beim freien Schreiben auswählen — der Rest füllt sich aus
|
||||||
|
den Daten, die ohnehin im System stehen.
|
||||||
|
|
||||||
|
- **Platzhalter, die sich selbst erklären.** Neben dem Editor steht die
|
||||||
|
vollständige Liste — Name, wofür er steht, woher der Wert kommt. Ein
|
||||||
|
Klick setzt ihn an die Cursorstelle; niemand muss `{{…}}`-Namen raten
|
||||||
|
oder abtippen. Verfügbar sind unter anderem Name und Geburtsdatum der
|
||||||
|
betreuten Person, ihre Anschrift, Betreuungsgericht und Aktenzeichen,
|
||||||
|
das Bestellungsdatum, der Stichtag und das heutige Datum.
|
||||||
|
|
||||||
|
- **Vier mitgelieferte Vorlagen** als Ausgangspunkt: Auskunftsersuchen an
|
||||||
|
eine noch unbekannte Stelle, Adressänderung nach Heimeinzug, Nachweis
|
||||||
|
der Vertretungsmacht (wenn eine Stelle die Auskunft verweigert) und die
|
||||||
|
Kurzfassung der Rückkaufswert-Anfrage. Sie lassen sich nicht kaputt
|
||||||
|
bearbeiten — „Als Kopie übernehmen" legt eine eigene Fassung an, das
|
||||||
|
Original bleibt liegen. Für Rückkaufswert und Kündigung führt die
|
||||||
|
Oberfläche weiter zuerst zu den fallbezogenen Erzeugern: Die rechnen
|
||||||
|
Kündigungstermine aus, was eine Vorlage nicht kann.
|
||||||
|
|
||||||
|
### Zwei Zusagen
|
||||||
|
|
||||||
|
**Nichts geht stillschweigend schief.** Fehlt ein Wert (etwa das
|
||||||
|
Aktenzeichen), steht im Brief eine sichtbare Lücke `[Aktenzeichen des
|
||||||
|
Gerichts]` — kein Leerraum, den erst der Empfänger bemerkt. Ein
|
||||||
|
Tippfehler im Platzhalternamen bricht nicht ab, sondern wird im Text
|
||||||
|
markiert und über dem Editor benannt.
|
||||||
|
|
||||||
|
**Der Bestellungsabsatz wird nicht vergessen.** Ohne den Nachweis der
|
||||||
|
Vertretungsmacht kommt keine Auskunft — und das fällt erst nach vier
|
||||||
|
Wochen auf. Beim Speichern einer Vorlage ohne Bestellungsabsatz kommt
|
||||||
|
deshalb eine Rückfrage. Wer ihn bewusst weglässt (etwa für die zweite
|
||||||
|
Mail an eine Stelle, die den Nachweis schon hat), bestätigt einmal und
|
||||||
|
wird nicht weiter gefragt. Ein ausformulierter Absatz zählt genauso wie
|
||||||
|
der Platzhalter.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Gelöschte Vorlagen liegen wie alles andere **30 Tage im Papierkorb**
|
||||||
|
und lassen sich zurückholen. Beim Vollexport der Akte sind sie mit
|
||||||
|
dabei.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Dreizehn Einheitstests für die Platzhalter-Ersetzung, darunter jeder
|
||||||
|
der drei Fälle (Wert einsetzen, sichtbare Lücke, unbekannter
|
||||||
|
Platzhalter) und die Bestellungsabsatz-Erkennung, die sich nicht von
|
||||||
|
einem halben Nachweis („Beschluss der Eigentümerversammlung") täuschen
|
||||||
|
lässt.
|
||||||
|
- Sechs End-to-End-Tests: die zweistufige Warnung, das Übernehmen einer
|
||||||
|
mitgelieferten Vorlage, die Auflösung im Editor samt markierter
|
||||||
|
Tippfehler und der Weg durch den Papierkorb.
|
||||||
|
|
||||||
|
## [1.6.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Rückkaufswerte aller Versicherungen in einem Zug anfragen**
|
||||||
|
([#6](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/6)).
|
||||||
|
Vor dem Vermögensverzeichnis und vor jedem Jahresbericht braucht es von
|
||||||
|
jeder Lebens- und Rentenversicherung den Rückkaufswert zum Stichtag —
|
||||||
|
jedes Mal dieselben fünf bis zehn Briefe. Das war der Handgriff, der im
|
||||||
|
Jahr am meisten Zeit gekostet hat.
|
||||||
|
|
||||||
|
Neu unter **Verträge → Rückkaufswerte anfragen**: Stichtag wählen, Liste
|
||||||
|
prüfen, ein Klick. Heraus kommt ein **ZIP mit einer Word-Datei je
|
||||||
|
Gesellschaft** plus Begleitblatt — und je Vertrag entsteht nebenbei die
|
||||||
|
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
|
||||||
|
Vermerk, dass angefragt wurde. Auf „Heute" steht danach unter *Wartet
|
||||||
|
auf andere*, worauf gewartet wird und seit wann.
|
||||||
|
|
||||||
|
- **Die Auswahl begründet sich selbst.** Vorausgewählt ist nur, was in
|
||||||
|
Frage kommt. Was nicht in Frage kommt, wird nicht stumm ausgeblendet,
|
||||||
|
sondern steht mit Grund in der Liste: „Ohne Gesellschaft fehlt der
|
||||||
|
Empfänger", „bereits vor 10 Tagen angefragt". Nur Vertragsarten, die
|
||||||
|
nie einen Rückkaufswert bilden (Strom, Haftpflicht), bleiben draußen.
|
||||||
|
Gekündigte Verträge bleiben anfragbar — bis zum Wirksamwerden besteht
|
||||||
|
der Vertrag, und genau dann will der Sozialhilfeträger den Wert sehen.
|
||||||
|
|
||||||
|
- **Die Zahl steht vor dem Klick auf dem Knopf** („4 Schreiben erzeugen").
|
||||||
|
Einen Fehlgriff soll man bemerken, bevor zehn Briefe entstehen — nicht
|
||||||
|
danach beim Aufräumen.
|
||||||
|
|
||||||
|
### Zwei Zusagen
|
||||||
|
|
||||||
|
**Kein Alles-oder-nichts.** Jeder Vertrag läuft für sich. Scheitert einer
|
||||||
|
(etwa am Objektspeicher), steht er mit Grund im Begleitblatt des ZIP —
|
||||||
|
die übrigen laufen durch. Eine Transaktion über alle wäre die falsche
|
||||||
|
Zusage: Wenn der neunte Brief scheitert, sollen die acht fertigen nicht
|
||||||
|
verworfen werden.
|
||||||
|
|
||||||
|
**Der Doppelklick kostet nichts.** Ein zweiter Lauf am selben Tag
|
||||||
|
überspringt bereits angefragte Verträge und sagt es im Begleitblatt —
|
||||||
|
keine doppelten Dokumente, keine doppelten Wiedervorlagen. Eine bewusste
|
||||||
|
zweite Anfrage nach einer Woche (Brief verloren) bleibt möglich: von Hand
|
||||||
|
anwählen genügt.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Zwölf Einheitstests für die Auswahllogik, darunter der Gleichschritt
|
||||||
|
von Sperrfrist und Wiedervorlage (beide 28 Tage — liefen sie
|
||||||
|
auseinander, würde gemahnt und zugleich neu angefragt).
|
||||||
|
- Drei End-to-End-Tests: Vorauswahl samt Begründungen, der volle Lauf mit
|
||||||
|
allen Nebenwirkungen in der Datenbank, und der Dublettenschutz beim
|
||||||
|
zweiten Lauf.
|
||||||
|
|
||||||
|
## [1.5.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Schreiben als E-Mail übernehmen**
|
||||||
|
([#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5)).
|
||||||
|
Vieles geht heute per E-Mail hinaus, nicht per Brief. Im Schreiben-Editor
|
||||||
|
gibt es jetzt den Umschalter **Als Brief / Als E-Mail**.
|
||||||
|
|
||||||
|
Die E-Mail-Fassung wird aus dem — womöglich schon bearbeiteten —
|
||||||
|
Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, die
|
||||||
|
gehören in den Brief und nicht in die Mail. **Der Bestellungsabsatz
|
||||||
|
bleibt drin** — ohne ihn kommt die Auskunft nicht. Betreff und Text sind
|
||||||
|
einzeln kopierbar.
|
||||||
|
|
||||||
|
- **Zwei Wege ins Mailprogramm.** Die offene Entscheidung aus dem Issue
|
||||||
|
(mailto oder .eml) ist mit *beides* beantwortet:
|
||||||
|
|
||||||
|
**„Im Mailprogramm öffnen"** (mailto) für kurze Texte — der Knopf
|
||||||
|
verschwindet, sobald der kodierte Link 1800 Zeichen übersteigt. mailto
|
||||||
|
übergibt den Text in der Adresszeile, deren Länge nirgends zugesichert
|
||||||
|
ist; ein stillschweigend halbierter Brief fällt erst beim Empfänger auf.
|
||||||
|
|
||||||
|
**„Als E-Mail-Entwurf (.eml)"** immer: eine Datei, die sich im
|
||||||
|
Mailprogramm als fertiger Entwurf öffnet — Adresse, Betreff und voller
|
||||||
|
Text garantiert vollständig, mit korrekt kodierten Umlauten. Die Datei
|
||||||
|
erfindet bewusst keinen Absender: den trägt das Mailprogramm beim
|
||||||
|
Senden selbst ein.
|
||||||
|
|
||||||
|
Beide Knöpfe erscheinen nur, wenn eine E-Mail-Adresse da ist — aus dem
|
||||||
|
gewählten Kontakt übernommen oder von Hand eingetragen. Ohne Adresse
|
||||||
|
bleibt das Kopieren.
|
||||||
|
|
||||||
|
- **Die Ablage kennt den Unterschied.** Eine als E-Mail abgelegte
|
||||||
|
Nachricht heißt in der Akte „E-Mail: …" und die PDF-Fassung trägt die
|
||||||
|
Empfängeradresse im Kopf — abgelegt wird die E-Mail-Fassung, nicht der
|
||||||
|
Brieftext. In der Akte soll stehen, was tatsächlich hinausging.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- 20 Einheitstests: die Ableitung der E-Mail-Fassung (nichts geht
|
||||||
|
stillschweigend verloren, der Bestellungsabsatz bleibt), die
|
||||||
|
mailto-Längengrenze **nach** der Kodierung, das .eml-Format samt
|
||||||
|
Umlauten nach RFC 2047 und quoted-printable.
|
||||||
|
- Vier End-to-End-Tests: Fassung ohne Briefkopf, .eml-Download, kein
|
||||||
|
Mailknopf ohne Adresse, Ablage als gekennzeichnete E-Mail.
|
||||||
|
|
||||||
|
## [1.4.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Schreiben erzeugen, ausgeben und ablegen**
|
||||||
|
([#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4)).
|
||||||
|
Die Anschreiben an Versicherer und Behörden gab es bisher nur als Text
|
||||||
|
zum Kopieren. Danach war Handarbeit: in Word nachbauen, ausdrucken,
|
||||||
|
abschicken — und noch einmal von Hand als Dokument in die Akte legen.
|
||||||
|
Vier Schritte, und der letzte wurde am häufigsten vergessen. Dann fehlt
|
||||||
|
der Nachweis genau dort, wo er gebraucht wird.
|
||||||
|
|
||||||
|
Neu unter **Schreiben**: Text bearbeiten, als **Word-Datei**
|
||||||
|
herunterladen, **in die Akte legen** und dabei eine **Wiedervorlage**
|
||||||
|
setzen — in einem Zug.
|
||||||
|
|
||||||
|
Der Text bleibt vollständig bearbeitbar. Jeder Fall hat eine
|
||||||
|
Besonderheit, die in den Brief gehört; ein Formulartext, den man nicht
|
||||||
|
mehr ändern kann, wird früher oder später falsch abgeschickt.
|
||||||
|
|
||||||
|
- **Schreiben ohne Vertrag und ohne Vorgang.** Bisher ging ein Anschreiben
|
||||||
|
nur zu einem vorhandenen Datensatz. Am Anfang einer Betreuung schreibt
|
||||||
|
man aber gerade an Stellen, zu denen es noch keinen gibt — man fragt ja
|
||||||
|
an, um zu erfahren, ob dort überhaupt etwas besteht. Jetzt geht es an
|
||||||
|
einen **Kontakt** oder an eine **frei getippte Anschrift**, aus der sich
|
||||||
|
mit einem Klick ein Kontakt anlegen lässt. So wächst das Adressbuch
|
||||||
|
nebenbei beim Schreiben statt durch Pflege.
|
||||||
|
|
||||||
|
- **Die Wiedervorlage entsteht mit.** Rückkaufswert 28 Tage, Kündigung 21,
|
||||||
|
Sachstandsanfrage 42 — jeweils änderbar, und daneben steht, warum die
|
||||||
|
Frist so lang ist. Bei einer Sachstandsanfrage wird zusätzlich der letzte
|
||||||
|
Kontakt am Vorgang nachgezogen, damit *Wartet auf andere* auf „Heute"
|
||||||
|
stimmt.
|
||||||
|
|
||||||
|
### Zwei Entscheidungen, die erklärt gehören
|
||||||
|
|
||||||
|
**In die Akte kommt eine PDF-Fassung, heruntergeladen wird eine
|
||||||
|
Word-Datei.** Der Objektspeicher nimmt nur PDF und Bilder an, geprüft an
|
||||||
|
den Magic Bytes — eine Word-Datei ist ein ZIP-Archiv, sie durchzulassen
|
||||||
|
hieße jedes ZIP durchzulassen. Diese Prüfung ist eine bewusste Zusage der
|
||||||
|
Anwendung; sie für eine Bequemlichkeit aufzuweichen wäre der falsche
|
||||||
|
Handel. Und sachlich ist PDF für den Nachweis ohnehin das richtige Format:
|
||||||
|
Es ändert sich nicht mehr.
|
||||||
|
|
||||||
|
Daraus folgt eine Einschränkung, die auch in der Oberfläche steht:
|
||||||
|
**Änderst du die Word-Datei danach noch**, weicht die Akte davon ab. Dann
|
||||||
|
lieber dort fertig schreiben, den Text zurückkopieren und erst dann
|
||||||
|
ablegen.
|
||||||
|
|
||||||
|
**Der Berichtssatz wurde nicht umgebogen.** Die vorhandene DOCX-Ausgabe
|
||||||
|
setzt einen Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief
|
||||||
|
braucht Absender, Anschriftenfeld, Datumszeile und Grußformel. Beides in
|
||||||
|
eine Funktion zu zwingen hätte beide Ausgaben schlechter gemacht.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Zehn Einheitstests, darunter der Gleichschritt der Wiedervorlagefristen:
|
||||||
|
Sie stehen an zwei Stellen und dürfen nicht auseinanderlaufen, sonst
|
||||||
|
mahnt die Anwendung zu zwei verschiedenen Terminen.
|
||||||
|
- Fünf End-to-End-Tests: Entwurf mit Bestellungsabsatz, Vertragsbezug,
|
||||||
|
Word-Download, Ablage samt Wiedervorlage und Kontakt aus freier
|
||||||
|
Anschrift.
|
||||||
|
|
||||||
|
Zwei Fehler hat erst der Browsertest gefunden — beide in den Tests
|
||||||
|
selbst: Die Dokumentenliste rendert Tabelle **und** Karten, je nach
|
||||||
|
Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
|
||||||
|
unsichtbare Fassung sieht aus wie ein Fehler in der Anwendung.
|
||||||
|
|
||||||
## [1.3.0] – 2026-08-23
|
## [1.3.0] – 2026-08-23
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
@@ -700,7 +1171,16 @@ 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.3.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
|
||||||
|
[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0
|
||||||
|
[1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0
|
||||||
|
[1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0
|
||||||
[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0
|
[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0
|
||||||
[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0
|
[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0
|
||||||
[1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit
|
|||||||
**3. Datenbank einrichten:**
|
**3. Datenbank einrichten:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma migrate dev --name fundament
|
npm run setup # Migrationen, Volltextsuche, Audit-Schutz, Beispieldaten
|
||||||
npm run db:sql # Volltextsuche und Audit-Schutz
|
|
||||||
npm run db:seed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Das ist die Kurzform für `db:deploy`, `db:sql` und `db:seed` — die drei
|
||||||
|
Schritte laufen ohne Rückfrage durch.
|
||||||
|
|
||||||
**4. Starten:**
|
**4. Starten:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -513,6 +514,35 @@ npm run protokoll # Bauprotokoll aus der Git-Historie
|
|||||||
npm run version -- patch # Version anheben, Changelog vorbereiten
|
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:
|
Konventionen — verbindlich für alle Beiträge:
|
||||||
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
|
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
|
||||||
Schnittstellen zwischen den Modulen:
|
Schnittstellen zwischen den Modulen:
|
||||||
@@ -545,7 +575,8 @@ Produktion.
|
|||||||
über den Abschnitt `environment:`.
|
über den Abschnitt `environment:`.
|
||||||
- Beim ersten Start eines Containers laufen die Migrationen automatisch
|
- Beim ersten Start eines Containers laufen die Migrationen automatisch
|
||||||
mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind
|
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
|
### Technische Grundlage
|
||||||
|
|
||||||
@@ -580,11 +611,11 @@ Fehler, die während des Baus aufgefallen sind.
|
|||||||
|
|
||||||
| | | nachzuzählen mit |
|
| | | nachzuzählen mit |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Zeilen TypeScript/TSX | 76.216 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
|
| Zeilen TypeScript/TSX | 79.102 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
|
||||||
| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
|
| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
|
||||||
| Seiten | 79 | `find src/app -name page.tsx \| wc -l` |
|
| Seiten | 82 | `find src/app -name page.tsx \| wc -l` |
|
||||||
| Unit-Tests (Vitest) | 560 | `npm test` |
|
| Unit-Tests (Vitest) | 602 | `npm test` |
|
||||||
| End-to-End-Tests (Playwright) | 63 | `npm run test:e2e` — 35 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
|
| End-to-End-Tests (Playwright) | 87 | `npm run test:e2e` — 47 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
|
||||||
| TypeScript-Fehler | 0 | `npm run typecheck` |
|
| TypeScript-Fehler | 0 | `npm run typecheck` |
|
||||||
|
|
||||||
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
|
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
|
||||||
|
|||||||
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
|
|||||||
|
|
||||||
# Auf die letzte veröffentlichte Fassung wechseln,
|
# Auf die letzte veröffentlichte Fassung wechseln,
|
||||||
# nicht auf den laufenden Entwicklungsstand
|
# nicht auf den laufenden Entwicklungsstand
|
||||||
git checkout v1.3.0
|
git checkout v1.10.2
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
||||||
git describe --tags # v1.3.0
|
git describe --tags # v1.10.2
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```bash
|
```bash
|
||||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.3.0","commit":"..."}
|
# {"status":"bereit","version":"1.10.2","commit":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, sieh in die Logs:
|
Kommt hier nichts, sieh in die Logs:
|
||||||
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://10.0.0.5:3000/api/gesundheit
|
curl -s http://10.0.0.5:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.3.0",...}
|
# {"status":"bereit","version":"1.10.2",...}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
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.
|
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
|
||||||
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
||||||
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
||||||
- Unten links in der Seitenleiste steht `Version 1.3.0 · <commit>`.
|
- Unten links in der Seitenleiste steht `Version 1.10.2 · <commit>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
|
|||||||
| Aufruf | Wirkung |
|
| Aufruf | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
|
||||||
| `./scripts/update.sh v1.3.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 --pruefen` | nur nachsehen, nichts ändern |
|
||||||
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
|
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
|
||||||
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
|
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
|
||||||
@@ -913,7 +913,7 @@ git fetch --tags
|
|||||||
git log --oneline HEAD..origin/main
|
git log --oneline HEAD..origin/main
|
||||||
|
|
||||||
# 3. Umschalten
|
# 3. Umschalten
|
||||||
git checkout v1.3.0
|
git checkout v1.10.2
|
||||||
|
|
||||||
# 4. Neu bauen und starten
|
# 4. Neu bauen und starten
|
||||||
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
|
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
|
||||||
|
|||||||
+1
-1
@@ -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
|
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
|
||||||
als wichtiger herausstellt, rutscht nach oben.
|
als wichtiger herausstellt, rutscht nach oben.
|
||||||
|
|
||||||
Stand: 23.08.2026 · Aktuelle Fassung: **1.3.0** ([CHANGELOG](../CHANGELOG.md))
|
Stand: 23.08.2026 · Aktuelle Fassung: **1.10.2** ([CHANGELOG](../CHANGELOG.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+187
-187
@@ -1,187 +1,187 @@
|
|||||||
# Schema-Wünsche aus Etappe 3 „Papier“
|
# Schema-Wünsche aus Etappe 3 „Papier“
|
||||||
|
|
||||||
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
|
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
|
||||||
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
|
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
|
||||||
funktioniert, wäre aber sauberer, wenn es im Modell stünde.
|
funktioniert, wäre aber sauberer, wenn es im Modell stünde.
|
||||||
|
|
||||||
Reihenfolge: was am meisten weh tut, steht oben.
|
Reihenfolge: was am meisten weh tut, steht oben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
|
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
|
||||||
|
|
||||||
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
|
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
|
||||||
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
|
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
|
||||||
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
|
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
|
||||||
Prüfung in `dokumentVerknuepfen`).
|
Prüfung in `dokumentVerknuepfen`).
|
||||||
|
|
||||||
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
|
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
|
||||||
Seed oder ein späterer Import das nicht verletzen kann:
|
Seed oder ein späterer Import das nicht verletzen kann:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
|
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
|
||||||
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
|
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
|
||||||
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
|
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
|
||||||
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
|
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
|
||||||
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
|
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
|
||||||
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
|
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
|
||||||
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
|
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
|
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
|
||||||
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
|
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
|
||||||
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
|
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
|
||||||
erklärbar.
|
erklärbar.
|
||||||
|
|
||||||
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
|
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
|
||||||
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
|
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
|
||||||
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
|
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
|
||||||
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
|
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
|
||||||
WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
|
WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. `Dokument.ocrStatus` ist ein freier String
|
## 2. `Dokument.ocrStatus` ist ein freier String
|
||||||
|
|
||||||
**Stand:** `ocrStatus String?` mit dem Kommentar
|
**Stand:** `ocrStatus String?` mit dem Kommentar
|
||||||
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
|
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
|
||||||
|
|
||||||
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
|
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
|
||||||
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
|
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
|
||||||
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
|
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
|
||||||
heute erst auf, wenn das Abzeichen leer bleibt.
|
heute erst auf, wenn das Abzeichen leer bleibt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Kein Index auf `Dokument.objektSchluessel`
|
## 3. Kein Index auf `Dokument.objektSchluessel`
|
||||||
|
|
||||||
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
|
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
|
||||||
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
|
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
|
||||||
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
|
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
|
||||||
|
|
||||||
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
|
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
|
||||||
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
|
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Keine Dublettenerkennung über die Prüfsumme
|
## 4. Keine Dublettenerkennung über die Prüfsumme
|
||||||
|
|
||||||
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
|
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
|
||||||
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
|
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
|
||||||
|
|
||||||
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
|
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
|
||||||
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
|
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
|
||||||
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
|
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
|
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
|
||||||
|
|
||||||
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
|
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
|
||||||
Verknüpfen muss deshalb über die Beziehung gehen
|
Verknüpfen muss deshalb über die Beziehung gehen
|
||||||
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
|
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
|
||||||
|
|
||||||
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
|
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
|
||||||
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
|
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
|
||||||
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
|
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
|
||||||
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
|
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
|
||||||
passieren.
|
passieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
|
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
|
||||||
|
|
||||||
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
|
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
|
||||||
OCR-Lauf zuletzt versucht wurde, steht nirgends.
|
OCR-Lauf zuletzt versucht wurde, steht nirgends.
|
||||||
|
|
||||||
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
|
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
|
||||||
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
|
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
|
||||||
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
|
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
|
||||||
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
|
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
|
||||||
bei jedem Nachziehen erneut durch Tesseract zu schicken.
|
bei jedem Nachziehen erneut durch Tesseract zu schicken.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
|
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
|
||||||
|
|
||||||
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
|
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
|
||||||
festgehalten gehört. Bei `Dokument.ocrText` steht:
|
festgehalten gehört. Bei `Dokument.ocrText` steht:
|
||||||
|
|
||||||
> „die Datei selbst liegt verschlüsselt im Objektspeicher“
|
> „die Datei selbst liegt verschlüsselt im Objektspeicher“
|
||||||
|
|
||||||
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
|
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
|
||||||
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
|
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
|
||||||
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
|
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
|
||||||
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
|
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
|
||||||
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
|
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
|
||||||
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
|
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
|
||||||
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
|
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
|
||||||
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
|
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
|
||||||
|
|
||||||
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
|
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
|
||||||
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
|
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
|
||||||
|
|
||||||
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
|
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
|
||||||
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
|
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
|
||||||
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
|
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
|
||||||
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
|
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
|
||||||
berührt.
|
berührt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Anhang: kein Schema-Wunsch, aber dringend
|
# Anhang: kein Schema-Wunsch, aber dringend
|
||||||
|
|
||||||
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
|
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
|
||||||
|
|
||||||
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
|
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
|
||||||
also auch die Berichte aus Etappe 5.
|
also auch die Berichte aus Etappe 5.
|
||||||
|
|
||||||
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
|
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
|
||||||
|
|
||||||
```
|
```
|
||||||
TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
|
TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
|
||||||
at @react-pdf/textkit/lib/textkit.js:3090
|
at @react-pdf/textkit/lib/textkit.js:3090
|
||||||
```
|
```
|
||||||
|
|
||||||
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
|
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
|
||||||
schuld: in reinem Node reproduziert es genauso.
|
schuld: in reinem Node reproduziert es genauso.
|
||||||
|
|
||||||
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
|
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
|
||||||
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
|
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
|
||||||
inzwischen weitergewandert:
|
inzwischen weitergewandert:
|
||||||
|
|
||||||
| Paket | installiert | von renderer 4.1.6 erwartet |
|
| Paket | installiert | von renderer 4.1.6 erwartet |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `@react-pdf/font` | 3.1.0 | `^3.0.1` |
|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` |
|
||||||
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
|
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
|
||||||
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
|
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
|
||||||
|
|
||||||
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
|
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
|
||||||
löste die Schicht darunter den bloßen Namen selbst auf.
|
löste die Schicht darunter den bloßen Namen selbst auf.
|
||||||
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
|
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
|
||||||
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
|
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
|
||||||
beiden klafft eine Lücke.
|
beiden klafft eine Lücke.
|
||||||
|
|
||||||
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
|
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
|
||||||
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
|
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
|
||||||
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
|
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
|
||||||
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
|
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
|
||||||
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
|
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
|
||||||
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
|
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
|
||||||
|
|
||||||
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
|
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
|
||||||
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
|
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@react-pdf/layout": "4.2.x",
|
"@react-pdf/layout": "4.2.x",
|
||||||
"@react-pdf/textkit": "5.x"
|
"@react-pdf/textkit": "5.x"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
|
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
|
||||||
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
|
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
|
||||||
Unterabhängigkeiten wieder zusammenpassen. Danach kann
|
Unterabhängigkeiten wieder zusammenpassen. Danach kann
|
||||||
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
|
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
|
||||||
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
|
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
|
||||||
|
|||||||
+243
-243
@@ -1,243 +1,243 @@
|
|||||||
# Schema-Wünsche aus Etappe 5 „Behörde“
|
# Schema-Wünsche aus Etappe 5 „Behörde“
|
||||||
|
|
||||||
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
|
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
|
||||||
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
|
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
|
||||||
Behelf, der jetzt an seiner Stelle steht.
|
Behelf, der jetzt an seiner Stelle steht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
|
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
|
||||||
|
|
||||||
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
|
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
|
||||||
Sicht des Moduls, das die Gegenüberstellung baut.
|
Sicht des Moduls, das die Gegenüberstellung baut.
|
||||||
|
|
||||||
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
|
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
|
||||||
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
|
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
|
||||||
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
|
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
|
||||||
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
|
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
|
||||||
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
|
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
|
||||||
Pflegegrad 3 um 1.319 Euro im Monat.
|
Pflegegrad 3 um 1.319 Euro im Monat.
|
||||||
|
|
||||||
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
|
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
|
||||||
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in
|
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in
|
||||||
`src/lib/sozialhilfe/heimkosten.ts`):
|
`src/lib/sozialhilfe/heimkosten.ts`):
|
||||||
|
|
||||||
```
|
```
|
||||||
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
|
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
|
||||||
+ unterkunftVerpflegung + investitionskosten
|
+ unterkunftVerpflegung + investitionskosten
|
||||||
+ ausbildungsumlage + zusatzleistungen
|
+ ausbildungsumlage + zusatzleistungen
|
||||||
+ barbetrag + bekleidungspauschale
|
+ barbetrag + bekleidungspauschale
|
||||||
|
|
||||||
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
|
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
|
||||||
+ sozialhilfe + eigenmittel
|
+ sozialhilfe + eigenmittel
|
||||||
|
|
||||||
Deckungslücke = Bedarf − Deckung
|
Deckungslücke = Bedarf − Deckung
|
||||||
```
|
```
|
||||||
|
|
||||||
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
|
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
|
||||||
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
|
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
|
||||||
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
|
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
|
||||||
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
|
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
|
||||||
|
|
||||||
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
|
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
|
||||||
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
|
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
|
||||||
auf der Heimrechnung als eigene Abzugsposition.
|
auf der Heimrechnung als eigene Abzugsposition.
|
||||||
|
|
||||||
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
|
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
|
||||||
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
|
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
|
||||||
kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der
|
kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der
|
||||||
Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu
|
Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu
|
||||||
speichern.
|
speichern.
|
||||||
|
|
||||||
**Wunsch, in dieser Reihenfolge:**
|
**Wunsch, in dieser Reihenfolge:**
|
||||||
|
|
||||||
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
|
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
|
||||||
nicht nur hier:
|
nicht nur hier:
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
|
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
|
||||||
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
|
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
|
||||||
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
|
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
|
||||||
/// als Deckung, sondern nur nachrichtlich.
|
/// als Deckung, sondern nur nachrichtlich.
|
||||||
pflegeEigenanteilCent BigInt @default(0)
|
pflegeEigenanteilCent BigInt @default(0)
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
|
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
|
||||||
Modul raten, was in der Zeile steht:
|
Modul raten, was in der Zeile steht:
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
enum Kostenlesart {
|
enum Kostenlesart {
|
||||||
EIGENANTEIL_NETTO
|
EIGENANTEIL_NETTO
|
||||||
PFLEGESATZ_BRUTTO
|
PFLEGESATZ_BRUTTO
|
||||||
}
|
}
|
||||||
|
|
||||||
lesart Kostenlesart @default(EIGENANTEIL_NETTO)
|
lesart Kostenlesart @default(EIGENANTEIL_NETTO)
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
|
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
|
||||||
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
|
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
|
||||||
die Summe könnte gegen sie geprüft werden.
|
die Summe könnte gegen sie geprüft werden.
|
||||||
|
|
||||||
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
|
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
|
||||||
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
|
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
|
||||||
Eigenanteil ein und lässt `pflegekasseCent` auf 0.
|
Eigenanteil ein und lässt `pflegekasseCent` auf 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. `Nachforderung.checklistenSchluessel` — wichtig
|
## 2. `Nachforderung.checklistenSchluessel` — wichtig
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
|
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
|
||||||
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
|
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
|
||||||
checklistenSchluessel String?
|
checklistenSchluessel String?
|
||||||
|
|
||||||
@@unique([antragId, checklistenSchluessel])
|
@@unique([antragId, checklistenSchluessel])
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
|
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
|
||||||
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
|
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
|
||||||
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
|
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
|
||||||
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
|
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
|
||||||
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
|
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
|
||||||
entsteht beim nächsten Übernehmen eine Dublette.
|
entsteht beim nächsten Übernehmen eine Dublette.
|
||||||
|
|
||||||
**Behelf**: Vergleich über die Bezeichnung in
|
**Behelf**: Vergleich über die Bezeichnung in
|
||||||
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
|
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Rückkaufswert am Vertrag — wichtig
|
## 3. Rückkaufswert am Vertrag — wichtig
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
model Vertrag {
|
model Vertrag {
|
||||||
// ...
|
// ...
|
||||||
rueckkaufswertCent BigInt?
|
rueckkaufswertCent BigInt?
|
||||||
rueckkaufswertStichtag DateTime? @db.Date
|
rueckkaufswertStichtag DateTime? @db.Date
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
|
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
|
||||||
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
|
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
|
||||||
ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die
|
ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die
|
||||||
Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die
|
Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die
|
||||||
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
|
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
|
||||||
zusätzlich ein Vermögensposten angelegt wurde.
|
zusätzlich ein Vermögensposten angelegt wurde.
|
||||||
|
|
||||||
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
|
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
|
||||||
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
|
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
|
||||||
|
|
||||||
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
|
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
|
||||||
Wert haben können (`MIT_RUECKKAUFSWERT` aus
|
Wert haben können (`MIT_RUECKKAUFSWERT` aus
|
||||||
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
|
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
|
||||||
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
|
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
|
||||||
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
|
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
|
||||||
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
|
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
|
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
|
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
|
||||||
rechtsbehelfsbelehrungVorhanden Boolean @default(true)
|
rechtsbehelfsbelehrungVorhanden Boolean @default(true)
|
||||||
rechtsbehelfsbelehrungBemerkung String?
|
rechtsbehelfsbelehrungBemerkung String?
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste
|
**Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste
|
||||||
Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“).
|
Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“).
|
||||||
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
|
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
|
||||||
das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
|
das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
|
||||||
wenn man sich im Widerspruchsverfahren darauf beruft.
|
wenn man sich im Widerspruchsverfahren darauf beruft.
|
||||||
|
|
||||||
**Behelf**: `belehrungZuMonaten()` in
|
**Behelf**: `belehrungZuMonaten()` in
|
||||||
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
|
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
|
||||||
Weiteres in `notizenVerschluesselt`.
|
Weiteres in `notizenVerschluesselt`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
|
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
widerspruchFristId String? @unique
|
widerspruchFristId String? @unique
|
||||||
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
|
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
|
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
|
||||||
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
|
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
|
||||||
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
|
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
|
||||||
aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter
|
aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter
|
||||||
Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids
|
Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids
|
||||||
mitziehen. Dasselbe gilt für
|
mitziehen. Dasselbe gilt für
|
||||||
`"sozialhilfe-nachforderung-<nachforderungId>"`.
|
`"sozialhilfe-nachforderung-<nachforderungId>"`.
|
||||||
|
|
||||||
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
|
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
|
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
|
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
|
||||||
gezahltCent BigInt?
|
gezahltCent BigInt?
|
||||||
gezahltAm DateTime? @db.Date
|
gezahltAm DateTime? @db.Date
|
||||||
rechnungsnummer String?
|
rechnungsnummer String?
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
|
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
|
||||||
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
|
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
|
||||||
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
|
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
|
||||||
Träger nachzufragen.
|
Träger nachzufragen.
|
||||||
|
|
||||||
**Behelf**: `bemerkung`.
|
**Behelf**: `bemerkung`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
|
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
|
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
|
||||||
abwesenheitsminderungCent BigInt @default(0)
|
abwesenheitsminderungCent BigInt @default(0)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
|
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
|
||||||
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
|
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
|
||||||
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
|
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
|
||||||
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
|
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
|
||||||
nicht festhalten.
|
nicht festhalten.
|
||||||
|
|
||||||
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
|
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
|
||||||
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
|
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
|
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
|
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
|
||||||
antragsschlussMonat Int?
|
antragsschlussMonat Int?
|
||||||
antragsschlussTag Int?
|
antragsschlussTag Int?
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
|
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
|
||||||
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
|
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
|
||||||
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
|
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
|
||||||
statt sie von Hand anzulegen.
|
statt sie von Hand anzulegen.
|
||||||
|
|
||||||
**Behelf**: `antragsschluss` als Text im Katalog
|
**Behelf**: `antragsschluss` als Text im Katalog
|
||||||
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
|
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
|
||||||
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
|
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
|
||||||
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
|
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. `Nachforderung.notizenVerschluesselt` — klein
|
## 9. `Nachforderung.notizenVerschluesselt` — klein
|
||||||
|
|
||||||
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
|
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
|
||||||
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
|
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
|
||||||
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
|
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
|
||||||
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
|
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
|
||||||
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
|
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
|
||||||
|
|
||||||
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
|
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
|
||||||
verschlüsselt werden sollen, gehört `Nachforderung` in
|
verschlüsselt werden sollen, gehört `Nachforderung` in
|
||||||
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
|
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
|
||||||
|
|||||||
@@ -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 ')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ansprechpartner am Vorgang — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Geprüft wird die Zusage, um die es geht: Ein Schreiben zum Vorgang
|
||||||
|
* redet die Sachbearbeiterin namentlich an und trägt ihren Namen über
|
||||||
|
* der Behörde — ohne benannten Ansprechpartner bleibt alles beim Alten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KENNUNG = 'Prüfhausen Partner'
|
||||||
|
|
||||||
|
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.vorgang.deleteMany({ where: { titel: { contains: KENNUNG } } })
|
||||||
|
await db.kontakt.deleteMany({ where: { name: { contains: KENNUNG } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Ansprechpartner am Vorgang', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
await db.kontakt.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
name: `${KENNUNG} Petra Sachs`,
|
||||||
|
organisation: `${KENNUNG} Bezirk`,
|
||||||
|
rolle: 'SACHBEARBEITER_BEZIRK',
|
||||||
|
anrede: 'FRAU',
|
||||||
|
telefon: '0921 604-1234',
|
||||||
|
strasse: 'Cottenbacher Str. 23',
|
||||||
|
plz: '95445',
|
||||||
|
ort: 'Bayreuth',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.vorgang.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
titel: `${KENNUNG} Antrag auf Hilfe zur Pflege`,
|
||||||
|
kategorie: 'SOZIALHILFE',
|
||||||
|
status: 'WARTET_AUF_DRITTE',
|
||||||
|
gegenstelle: `${KENNUNG} Bezirk`,
|
||||||
|
aktenzeichen: 'HzP 2026/4711',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bleibt ohne benannten Ansprechpartner bei der allgemeinen Anrede', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vorgaenge')
|
||||||
|
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
|
||||||
|
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
|
||||||
|
|
||||||
|
await expect(page.getByText('Keiner benannt', { exact: false }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
|
||||||
|
const text = await page.locator('textarea#sachstand-text').inputValue()
|
||||||
|
expect(text).toContain('Sehr geehrte Damen und Herren,')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Kontakt als Ansprechpartner wählen und zeigt ihn an', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vorgaenge')
|
||||||
|
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
|
||||||
|
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('select[name="ansprechpartnerId"]')
|
||||||
|
.selectOption({ label: `${KENNUNG} Petra Sachs · ${KENNUNG} Bezirk` })
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
const inhalt = await page.locator('body').innerText()
|
||||||
|
// Vor dem Anruf weiß man, wen man fragt — samt Durchwahl.
|
||||||
|
expect(inhalt).toContain(`${KENNUNG} Petra Sachs`)
|
||||||
|
expect(inhalt).toContain('0921 604-1234')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redet die Sachstandsanfrage namentlich an', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vorgaenge')
|
||||||
|
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
|
||||||
|
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
|
||||||
|
const text = await page.locator('textarea#sachstand-text').inputValue()
|
||||||
|
expect(text).toContain('Sehr geehrte Frau Sachs,')
|
||||||
|
expect(text).not.toContain('Sehr geehrte Damen und Herren,')
|
||||||
|
// Der Name steht über der Behörde.
|
||||||
|
const zeilen = text.split('\n')
|
||||||
|
expect(zeilen[0]).toContain('Petra Sachs')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('übernimmt Name und Anschrift in das Schreiben zum Vorgang', async ({ page }) => {
|
||||||
|
const vorgangId = await mitDb(async (db) => {
|
||||||
|
const vorgang = await db.vorgang.findFirst({
|
||||||
|
where: { titel: { contains: KENNUNG }, ansprechpartnerId: { not: null } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
return vorgang?.id ?? ''
|
||||||
|
})
|
||||||
|
expect(vorgangId).not.toBe('')
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto(`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgangId}`)
|
||||||
|
|
||||||
|
await expect(page.locator('textarea#text')).toBeVisible()
|
||||||
|
const text = await page.locator('textarea#text').inputValue()
|
||||||
|
expect(text).toContain('Sehr geehrte Frau Sachs,')
|
||||||
|
|
||||||
|
// Empfänger und Anschrift sind vorbelegt, nichts abzutippen.
|
||||||
|
await expect(page.locator('input[name="empfaengerName"]').first()).toHaveValue(
|
||||||
|
`${KENNUNG} Petra Sachs`,
|
||||||
|
)
|
||||||
|
expect(await page.locator('textarea#empfaengerAnschrift').first().inputValue()).toContain(
|
||||||
|
'95445 Bayreuth',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hält den Wechsel des Ansprechpartners mit Namen im Protokoll fest', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vorgaenge')
|
||||||
|
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
|
||||||
|
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
|
||||||
|
|
||||||
|
// Zurück auf „keiner benannt" — der Wechsel muss nachvollziehbar sein.
|
||||||
|
await page.locator('select[name="ansprechpartnerId"]').selectOption('')
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
|
||||||
|
|
||||||
|
// Im Protokoll steht der Name, nicht die Id: Zwei cuids nebeneinander
|
||||||
|
// sind protokolliert, aber nicht nachvollziehbar.
|
||||||
|
await page.goto('/protokoll')
|
||||||
|
// textContent statt innerText: Die Felderliste steckt in einem
|
||||||
|
// zugeklappten <details>.
|
||||||
|
const protokoll = (await page.locator('body').textContent()) ?? ''
|
||||||
|
expect(protokoll).toContain('ansprechpartner')
|
||||||
|
expect(protokoll).toContain(`${KENNUNG} Petra Sachs`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreiben als E-Mail — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Geprüft wird der Weg, den die Einheitstests nicht sehen: Umschalten auf
|
||||||
|
* E-Mail, die abgeleitete Fassung ohne Briefkopf, der .eml-Download und
|
||||||
|
* die Ablage, die das Schreiben als E-Mail kennzeichnet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KONTAKT = 'Prüfhausen Versicherung (E-Mail-Test)'
|
||||||
|
|
||||||
|
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.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
|
||||||
|
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
|
||||||
|
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Schreiben als E-Mail', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
await db.kontakt.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
name: KONTAKT,
|
||||||
|
organisation: KONTAKT,
|
||||||
|
rolle: 'SONSTIGES',
|
||||||
|
email: 'service@pruefhausen.example',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leitet die E-Mail-Fassung ohne Briefkopf ab, mit Bestellungsabsatz', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
|
||||||
|
// Kontakt wählen - bringt die E-Mail-Adresse mit.
|
||||||
|
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
|
||||||
|
await expect(page.locator('input#empfaengerEmail')).toHaveValue(
|
||||||
|
'service@pruefhausen.example',
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Als E-Mail' }).click()
|
||||||
|
|
||||||
|
const mailtext = await page.locator('textarea#emailText').inputValue()
|
||||||
|
// Beginnt mit der Anrede - kein Absenderblock, keine Datumszeile.
|
||||||
|
expect(mailtext.startsWith('Sehr geehrte')).toBe(true)
|
||||||
|
expect(mailtext).not.toMatch(/^\d{1,2}\.\d{1,2}\.\d{4}$/m)
|
||||||
|
// Der Nachweis der Bestellung bleibt drin.
|
||||||
|
expect(mailtext).toMatch(/Betreuungsgericht|bestellt/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lädt den .eml-Entwurf herunter', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
|
||||||
|
await page.getByRole('button', { name: 'Als E-Mail' }).click()
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' }).click(),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.eml$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt ohne Adresse keine Mailprogramm-Knöpfe', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await page.getByRole('button', { name: 'Als E-Mail' }).click()
|
||||||
|
|
||||||
|
// Kopieren geht immer, die Programm-Knöpfe nicht.
|
||||||
|
await expect(page.getByRole('button', { name: 'Text kopieren' }).last()).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('link', { name: 'Im Mailprogramm öffnen' })).toHaveCount(0)
|
||||||
|
await expect(page.getByText(/Trag oben eine E-Mail-Adresse ein/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legt die E-Mail als solche gekennzeichnet in die Akte', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
|
||||||
|
|
||||||
|
await page.locator('input#betreff').fill('Prüfhausen Testanfrage')
|
||||||
|
await page.getByRole('button', { name: 'Als E-Mail' }).click()
|
||||||
|
await page.getByRole('button', { name: 'In die Akte legen' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In der Akte als E-Mail erkennbar, nicht als Brief.
|
||||||
|
await page.goto('/dokumente?richtung=AUSGANG')
|
||||||
|
await expect(page.locator('body')).toContainText('E-Mail: Prüfhausen Testanfrage')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -117,3 +117,46 @@ export function beispielPdf(text = 'Bescheid'): Buffer {
|
|||||||
|
|
||||||
return Buffer.from(pdf, 'latin1')
|
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.
|
||||||
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rückkaufswert am Vertrag — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Die Zusage, die hier geprüft wird: Der Wert steht dort, wo man ihn
|
||||||
|
* braucht, sein Stichtag steht daneben, ein alter Wert ist als alt
|
||||||
|
* erkennbar — und es ist derselbe Wert, den das Vermögensverzeichnis
|
||||||
|
* ausweist, nicht ein zweiter danebengelegter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KENNUNG = 'Prüfhausen Wert'
|
||||||
|
|
||||||
|
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.vermoegensposten.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
|
||||||
|
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tag vor N Tagen, als reines Datum. */
|
||||||
|
function vorTagen(tage: number): Date {
|
||||||
|
const heute = new Date()
|
||||||
|
const ziel = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate()))
|
||||||
|
ziel.setUTCDate(ziel.getUTCDate() - tage)
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Rückkaufswert am Vertrag', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
// Ein Vertrag mit frischem Wert, einer mit einem zwei Jahre alten,
|
||||||
|
// einer ohne jeden Wert.
|
||||||
|
const frisch = await db.vertrag.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Frisch`,
|
||||||
|
gesellschaft: `${KENNUNG} Leben AG`,
|
||||||
|
art: 'LEBENSVERSICHERUNG',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const alt = await db.vertrag.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Alt`,
|
||||||
|
gesellschaft: `${KENNUNG} Sterbekasse`,
|
||||||
|
art: 'STERBEGELDVERSICHERUNG',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.vertrag.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Ohne`,
|
||||||
|
gesellschaft: `${KENNUNG} Renten AG`,
|
||||||
|
art: 'RENTENVERSICHERUNG_PRIVAT',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.vermoegensposten.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
vertragId: frisch.id,
|
||||||
|
art: 'VERSICHERUNG',
|
||||||
|
bezeichnung: `${KENNUNG} Frisch`,
|
||||||
|
wertstaende: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
stichtag: vorTagen(30),
|
||||||
|
wertCent: 521055n,
|
||||||
|
wertquelle: 'Rückkaufswertmitteilung',
|
||||||
|
},
|
||||||
|
{ stichtag: vorTagen(400), wertCent: 480000n, wertquelle: 'Jahresmitteilung' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bewusst als „Sonstiges" verknüpft: Die Zuordnung zum Vertrag
|
||||||
|
// hängt an der Verknüpfung, nicht an der Art des Postens.
|
||||||
|
await db.vermoegensposten.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
vertragId: alt.id,
|
||||||
|
art: 'SONSTIGES',
|
||||||
|
bezeichnung: `${KENNUNG} Alt`,
|
||||||
|
wertstaende: {
|
||||||
|
create: [
|
||||||
|
{ stichtag: vorTagen(800), wertCent: 310000n, wertquelle: 'Jahresmitteilung' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt Wert und Stichtag in der Vertragsübersicht', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege')
|
||||||
|
|
||||||
|
const inhalt = await page.locator('body').innerText()
|
||||||
|
expect(inhalt).toContain('5.210,55')
|
||||||
|
// Ohne Wert bleibt die Zelle nicht leer, sondern sagt es.
|
||||||
|
expect(inhalt).toContain('noch nicht erfasst')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kennzeichnet einen über ein Jahr alten Wert', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege')
|
||||||
|
|
||||||
|
// Der 800 Tage alte Wert des „Alt"-Vertrags trägt das Abzeichen …
|
||||||
|
await expect(page.getByText('über ein Jahr alt').first()).toBeVisible()
|
||||||
|
|
||||||
|
// … und er wird trotz Posten-Art „Sonstiges" überhaupt gefunden.
|
||||||
|
expect(await page.locator('body').innerText()).toContain('3.100,00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt am Vertrag den letzten Wert und die Historie', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege')
|
||||||
|
await page.getByRole('link', { name: `${KENNUNG} Frisch` }).click()
|
||||||
|
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
|
||||||
|
await expect(page.getByText('Zuletzt mitgeteilt')).toBeVisible()
|
||||||
|
|
||||||
|
const inhalt = await page.locator('body').innerText()
|
||||||
|
expect(inhalt).toContain('Zuletzt mitgeteilt')
|
||||||
|
expect(inhalt).toContain('5.210,55')
|
||||||
|
// Der frühere Wert bleibt abrufbar — die Entwicklung ist die Aussage.
|
||||||
|
expect(inhalt).toContain('4.800,00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trägt einen neuen Wert am Vertrag ein — sichtbar auch im Vermögensverzeichnis', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege')
|
||||||
|
await page.getByRole('link', { name: `${KENNUNG} Ohne` }).click()
|
||||||
|
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Mitgeteilten Wert eintragen' }).click()
|
||||||
|
await page.locator('input[name="stichtag"]').last().fill('2026-08-01')
|
||||||
|
await page.locator('input[name="wert"]').fill('1.234,56')
|
||||||
|
await page.getByRole('button', { name: 'Wert aufnehmen' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Rückkaufswert in die Historie aufgenommen.')).toBeVisible()
|
||||||
|
expect(await page.locator('body').innerText()).toContain('1.234,56')
|
||||||
|
|
||||||
|
// Dieselbe Zahl, eine Quelle: Das Vermögensverzeichnis kennt sie auch.
|
||||||
|
await page.goto('/finanzen/vermoegen?stichtag=2026-08-31')
|
||||||
|
expect(await page.locator('body').innerText()).toContain('1.234,56')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreiben erzeugen — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Der eigentliche Gewinn dieses Issues ist nicht der Brieftext (den gab es
|
||||||
|
* schon), sondern was danach passiert: Word-Datei, Ablage als
|
||||||
|
* Ausgangsdokument, Wiedervorlage. Genau dieser Teil lässt sich mit
|
||||||
|
* Einheitstests nicht prüfen — er berührt Objektspeicher, Datenbank und
|
||||||
|
* Download.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERTRAG = 'Prüfhausen Police (Test Schreiben)'
|
||||||
|
const GESELLSCHAFT = 'Prüfhausen Lebensversicherung'
|
||||||
|
|
||||||
|
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.dokument.deleteMany({ where: { titel: { contains: 'Rückkaufswert' } } })
|
||||||
|
await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
|
||||||
|
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
|
||||||
|
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
|
||||||
|
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vertragAnlegen(): Promise<void> {
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
await db.vertrag.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: VERTRAG,
|
||||||
|
gesellschaft: GESELLSCHAFT,
|
||||||
|
art: 'LEBENSVERSICHERUNG',
|
||||||
|
beitragCent: 4250n,
|
||||||
|
rhythmus: 'MONATLICH',
|
||||||
|
beginnAm: new Date(Date.UTC(2015, 0, 1)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet die Vertrags-ID der AKTIVEN betreuten Person.
|
||||||
|
*
|
||||||
|
* Welche Person aktiv ist, haengt an einem Cookie — die Datenbank allein
|
||||||
|
* weiss es nicht. Der Weg ueber die Vertragsliste ist zugleich der Weg,
|
||||||
|
* den ein Mensch nimmt.
|
||||||
|
*/
|
||||||
|
async function vertragIdAusOberflaeche(page: import('@playwright/test').Page): Promise<string> {
|
||||||
|
await page.goto('/vertraege')
|
||||||
|
const link = page.getByRole('link', { name: new RegExp(VERTRAG.replace(/[()]/g, '.')) }).first()
|
||||||
|
await expect(link).toBeVisible()
|
||||||
|
const ziel = await link.getAttribute('href')
|
||||||
|
return (ziel ?? '').split('/').pop() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Schreiben erzeugen', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
await vertragAnlegen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('baut den Entwurf mit Bestellungsabsatz und lässt ihn bearbeiten', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
|
||||||
|
const brief = page.locator('textarea#text')
|
||||||
|
await expect(brief).toBeVisible()
|
||||||
|
|
||||||
|
const text = await brief.inputValue()
|
||||||
|
// Ohne den Nachweis der Vertretungsmacht antwortet keine Stelle —
|
||||||
|
// deshalb steht er auch im freien Entwurf drin.
|
||||||
|
expect(text).toMatch(/Betreuungsgericht|bestellt/i)
|
||||||
|
// Und die Lücken sind sichtbar, nicht stillschweigend leer.
|
||||||
|
expect(text).toContain('[')
|
||||||
|
|
||||||
|
await brief.fill(`${text}\n\nZusatz aus dem Test.`)
|
||||||
|
expect(await brief.inputValue()).toContain('Zusatz aus dem Test.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erzeugt aus dem Vertrag heraus einen Rückkaufswert-Entwurf', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const vertragId = await vertragIdAusOberflaeche(page)
|
||||||
|
expect(vertragId).not.toBe('')
|
||||||
|
|
||||||
|
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
|
||||||
|
|
||||||
|
await expect(page.locator('input#empfaengerName')).toHaveValue(GESELLSCHAFT)
|
||||||
|
await expect(page.locator('input#betreff')).not.toHaveValue('')
|
||||||
|
// Die Wiedervorlage ist vorbelegt — 28 Tage.
|
||||||
|
await expect(page.locator('input#wiedervorlageAm')).not.toHaveValue('')
|
||||||
|
|
||||||
|
const text = await page.locator('textarea#text').inputValue()
|
||||||
|
expect(text).toContain('Rückkaufswert')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('liefert die Word-Datei aus', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await page.locator('input#empfaengerName').fill('Prüfhausen Amt')
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('button', { name: 'Als Word-Datei' }).click(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Sprechender, sortierbarer Name mit Datum vorn.
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.docx$/)
|
||||||
|
expect(download.suggestedFilename()).toContain('Pruefhausen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legt das Schreiben in die Akte und setzt die Wiedervorlage', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const vertragId = await vertragIdAusOberflaeche(page)
|
||||||
|
|
||||||
|
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
|
||||||
|
await page.getByRole('button', { name: 'In die Akte legen' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
await expect(page.getByText(/Wiedervorlage zum/)).toBeVisible()
|
||||||
|
|
||||||
|
// Und es steht wirklich in der Dokumentenliste, als Ausgang.
|
||||||
|
//
|
||||||
|
// Bewusst ohne tr/li: Die Liste rendert Tabelle UND Karten, je nach
|
||||||
|
// Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
|
||||||
|
// unsichtbare Fassung sähe wie ein Fehler aus.
|
||||||
|
await page.goto('/dokumente?richtung=AUSGANG')
|
||||||
|
|
||||||
|
// Die Zusammenfassungszeile gibt es nur einmal — anders als den Titel,
|
||||||
|
// der in Tabelle UND Kartenliste steht.
|
||||||
|
await expect(page.getByText('1 Dokument passen zu den Filtern')).toBeVisible()
|
||||||
|
await expect(page.locator('body')).toContainText('Auskunft über Rückkaufswert')
|
||||||
|
await expect(page.locator('body')).toContainText('Ausgang')
|
||||||
|
|
||||||
|
// Die Wiedervorlage steht in der Fristenliste.
|
||||||
|
await page.goto('/fristen')
|
||||||
|
await expect(page.getByText(/Antwort abwarten/).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legt aus einer freien Anschrift einen Kontakt an', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
|
||||||
|
await page.locator('input#empfaengerName').fill('Prüfhausen Sozialamt')
|
||||||
|
await page.locator('textarea#empfaengerAnschrift').fill('Musterweg 3\n96450 Coburg')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Als Kontakt merken' }).click()
|
||||||
|
await expect(page.getByText(/steht jetzt in den Kontakten/)).toBeVisible()
|
||||||
|
|
||||||
|
// Beim nächsten Mal steht er zur Auswahl.
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await expect(page.locator('select#kontaktAuswahl')).toContainText('Prüfhausen Sozialamt')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serienabfrage der Rückkaufswerte — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Geprüft wird der ganze Lauf: Vorauswahl, ZIP-Download, die
|
||||||
|
* Nebenwirkungen (Anfragedatum, Wiedervorlage, Ausgangsdokumente) und der
|
||||||
|
* Dublettenschutz beim zweiten Lauf am selben Tag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KENNUNG = 'Prüfhausen Serie'
|
||||||
|
|
||||||
|
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.dokument.deleteMany({ where: { empfaenger: { contains: KENNUNG } } })
|
||||||
|
await db.frist.deleteMany({ where: { titel: { contains: KENNUNG } } })
|
||||||
|
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Serienabfrage', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
// Zwei anfragbare, eine ohne Rueckkaufswert, eine ohne Gesellschaft.
|
||||||
|
await db.vertrag.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Leben`,
|
||||||
|
gesellschaft: `${KENNUNG} Lebensversicherung`,
|
||||||
|
art: 'LEBENSVERSICHERUNG',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Sterbegeld`,
|
||||||
|
gesellschaft: `${KENNUNG} Sterbekasse`,
|
||||||
|
art: 'STERBEGELDVERSICHERUNG',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Haftpflicht`,
|
||||||
|
gesellschaft: `${KENNUNG} Allgemeine`,
|
||||||
|
art: 'HAFTPFLICHT',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
betreuteId: person.id,
|
||||||
|
bezeichnung: `${KENNUNG} Ohne Gesellschaft`,
|
||||||
|
gesellschaft: null,
|
||||||
|
art: 'LEBENSVERSICHERUNG',
|
||||||
|
status: 'AKTIV',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wählt richtig vor und benennt die Hindernisse', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege/serienabfrage/seite')
|
||||||
|
|
||||||
|
// Die beiden anfragbaren sind vorausgewählt …
|
||||||
|
await expect(
|
||||||
|
page.locator('li', { hasText: `${KENNUNG} Leben` }).locator('input[type="checkbox"]'),
|
||||||
|
).toBeChecked()
|
||||||
|
await expect(
|
||||||
|
page.locator('li', { hasText: `${KENNUNG} Sterbegeld` }).locator('input[type="checkbox"]'),
|
||||||
|
).toBeChecked()
|
||||||
|
|
||||||
|
// … die ohne Gesellschaft steht mit Begründung da, ohne Haken …
|
||||||
|
const ohne = page.locator('li', { hasText: `${KENNUNG} Ohne Gesellschaft` })
|
||||||
|
await expect(ohne).toContainText('nachtragen')
|
||||||
|
await expect(ohne.locator('input[type="checkbox"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
// … und die Haftpflicht taucht gar nicht erst auf.
|
||||||
|
await expect(page.locator('li', { hasText: `${KENNUNG} Haftpflicht` })).toHaveCount(0)
|
||||||
|
|
||||||
|
// Die Zusammenfassung steht auf dem Knopf, BEVOR etwas entsteht -
|
||||||
|
// und sie stimmt mit der Zahl der gesetzten Haken ueberein. Auf die
|
||||||
|
// absolute Zahl laesst sich nicht bauen: Der Demofall bringt eigene
|
||||||
|
// Vertraege mit.
|
||||||
|
const haken = await page.locator('input[type="checkbox"]:checked').count()
|
||||||
|
expect(haken).toBeGreaterThanOrEqual(2)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: new RegExp(`${haken} Schreiben erzeugen`) }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('führt den Lauf durch: ZIP, Akte, Wiedervorlagen, Anfragedatum', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege/serienabfrage/seite')
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(),
|
||||||
|
])
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_Rueckkaufswert-Anfragen\.zip$/)
|
||||||
|
|
||||||
|
// Nebenwirkungen in der Datenbank: Anfragedatum, Dokumente, Fristen.
|
||||||
|
const stand = await mitDb(async (db) => {
|
||||||
|
const angefragt = await db.vertrag.count({
|
||||||
|
where: {
|
||||||
|
bezeichnung: { in: [`${KENNUNG} Leben`, `${KENNUNG} Sterbegeld`] },
|
||||||
|
rueckkaufswertAngefragtAm: { not: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const dokumente = await db.dokument.count({
|
||||||
|
where: { empfaenger: { contains: KENNUNG }, richtung: 'AUSGANG' },
|
||||||
|
})
|
||||||
|
const fristen = await db.frist.count({
|
||||||
|
where: { titel: { contains: KENNUNG }, typ: 'WIEDERVORLAGE' },
|
||||||
|
})
|
||||||
|
return { angefragt, dokumente, fristen }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Beide betreute Personen haben die Verträge - angefragt wird aber nur
|
||||||
|
// bei der aktiven. Deshalb: genau 2 von 4.
|
||||||
|
expect(stand.angefragt).toBe(2)
|
||||||
|
expect(stand.dokumente).toBe(2)
|
||||||
|
expect(stand.fristen).toBe(2)
|
||||||
|
|
||||||
|
// Auf der Seite stehen die beiden jetzt als frisch angefragt.
|
||||||
|
await page.goto('/vertraege/serienabfrage/seite')
|
||||||
|
await expect(page.getByText(/bereits angefragt/).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erzeugt beim zweiten Lauf am selben Tag keine Dubletten', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/vertraege/serienabfrage/seite')
|
||||||
|
|
||||||
|
// Alles abwaehlen, dann NUR den frisch angefragten Vertrag anwaehlen.
|
||||||
|
// So prueft der Lauf genau den Dublettenschutz und fasst die uebrigen
|
||||||
|
// Vertraege nicht an.
|
||||||
|
const angehakte = page.locator('input[type="checkbox"]:checked')
|
||||||
|
while ((await angehakte.count()) > 0) {
|
||||||
|
await angehakte.first().uncheck()
|
||||||
|
}
|
||||||
|
const leben = page.locator('li', { hasText: `${KENNUNG} Leben` })
|
||||||
|
await leben.locator('input[type="checkbox"]').check()
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(),
|
||||||
|
])
|
||||||
|
expect(download.suggestedFilename()).toContain('.zip')
|
||||||
|
|
||||||
|
// Der Dublettenschutz greift: weiterhin genau ein Dokument und eine
|
||||||
|
// Frist für diesen Vertrag.
|
||||||
|
const stand = await mitDb(async (db) => {
|
||||||
|
const dokumente = await db.dokument.count({
|
||||||
|
where: { empfaenger: `${KENNUNG} Lebensversicherung` },
|
||||||
|
})
|
||||||
|
const fristen = await db.frist.count({
|
||||||
|
where: { titel: { contains: `${KENNUNG} Leben` }, typ: 'WIEDERVORLAGE' },
|
||||||
|
})
|
||||||
|
return { dokumente, fristen }
|
||||||
|
})
|
||||||
|
expect(stand.dokumente).toBe(1)
|
||||||
|
expect(stand.fristen).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { anmelden } from './hilfen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Schreibvorlagen — gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Der Weg über die Oberfläche: Vorlage anlegen (mit der zweistufigen
|
||||||
|
* Bestellungsabsatz-Warnung), aus einer mitgelieferten kopieren, beim
|
||||||
|
* Schreiben auswählen und die Platzhalter-Auflösung sehen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VORLAGE = 'Prüfhausen Testvorlage'
|
||||||
|
|
||||||
|
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.schreibvorlage.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
|
||||||
|
await db.schreibvorlage.deleteMany({ where: { name: { contains: '(eigene Fassung)' } } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Schreibvorlagen', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warnt ohne Bestellungsabsatz und speichert erst nach Bestätigung', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen/vorlagen')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Neue Vorlage' }).click()
|
||||||
|
await page.locator('input[name="name"]').fill(VORLAGE)
|
||||||
|
await page
|
||||||
|
.locator('textarea[name="text"]')
|
||||||
|
.fill('Sehr geehrte Damen und Herren,\n\nbitte um Auskunft zu {{betreute_name}}.\n\nMfG')
|
||||||
|
|
||||||
|
// Erster Versuch: Warnung, nicht gespeichert.
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText(/fehlt der Bestellungsabsatz/)).toBeVisible()
|
||||||
|
|
||||||
|
// Bestätigen und erneut speichern.
|
||||||
|
await page.getByText('Bewusst ohne Bestellungsabsatz speichern').click()
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText(`„${VORLAGE}“ ist gespeichert.`)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('speichert mit Platzhalter ohne Warnung', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen/vorlagen')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Neue Vorlage' }).click()
|
||||||
|
await page.locator('input[name="name"]').fill(`${VORLAGE} mit Absatz`)
|
||||||
|
await page
|
||||||
|
.locator('textarea[name="text"]')
|
||||||
|
.fill('Sehr geehrte Damen und Herren,\n\n{{bestellungsabsatz}}\n\nbitte um Auskunft.\n\nMfG')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText(`„${VORLAGE} mit Absatz“ ist gespeichert.`)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('übernimmt eine mitgelieferte Vorlage als Kopie', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen/vorlagen')
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('li', { hasText: 'Auskunftsersuchen' })
|
||||||
|
.getByRole('button', { name: 'Als Kopie übernehmen' })
|
||||||
|
.click()
|
||||||
|
|
||||||
|
// Der Editor öffnet mit den kopierten Inhalten.
|
||||||
|
await expect(page.locator('input[name="name"]')).toHaveValue(/eigene Fassung/)
|
||||||
|
const text = await page.locator('textarea[name="text"]').inputValue()
|
||||||
|
expect(text).toContain('{{bestellungsabsatz}}')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText(/ist gespeichert/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('löst die Platzhalter beim Erzeugen auf — mit sichtbaren Lücken', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
|
||||||
|
// Die gespeicherte Vorlage steht zur Wahl.
|
||||||
|
const wahl = page.locator('select#vorlageWahl')
|
||||||
|
await expect(wahl).toBeVisible()
|
||||||
|
await wahl.selectOption({ label: `${VORLAGE} mit Absatz` })
|
||||||
|
|
||||||
|
await page.waitForURL(/vorlageId=/)
|
||||||
|
const text = await page.locator('textarea#text').inputValue()
|
||||||
|
|
||||||
|
// Der Bestellungsabsatz ist aufgelöst (kein Platzhalter mehr) …
|
||||||
|
expect(text).not.toContain('{{bestellungsabsatz}}')
|
||||||
|
expect(text).toMatch(/Beschluss|bestellt/)
|
||||||
|
// … und der Rest des Textes steht drin.
|
||||||
|
expect(text).toContain('bitte um Auskunft.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('markiert unbekannte Platzhalter im Text und warnt', async ({ page }) => {
|
||||||
|
await mitDb(async (db) => {
|
||||||
|
const betreute = await db.betreute.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
for (const person of betreute) {
|
||||||
|
await db.schreibvorlage.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: person.id,
|
||||||
|
name: `${VORLAGE} kaputt`,
|
||||||
|
text: 'Sehr geehrte Damen und Herren,\n\n{{bestellungsabsatz}}\n\nHallo {{gibt_es_nicht}}.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/schreiben/neu?art=FREI')
|
||||||
|
await page.locator('select#vorlageWahl').selectOption({ label: `${VORLAGE} kaputt` })
|
||||||
|
await page.waitForURL(/vorlageId=/)
|
||||||
|
|
||||||
|
// Markiert im Text, benannt in der Warnung — kein Abbruch.
|
||||||
|
const text = await page.locator('textarea#text').inputValue()
|
||||||
|
expect(text).toContain('[Unbekannter Platzhalter: {{gibt_es_nicht}}]')
|
||||||
|
await expect(page.getByText(/unbekannte Platzhalter/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gelöschte Vorlage liegt im Papierkorb und kommt zurück', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen/vorlagen')
|
||||||
|
|
||||||
|
// Zweistufig - Loeschen fragt erst nach.
|
||||||
|
const eintrag = page.locator('li', { hasText: `${VORLAGE} mit Absatz` })
|
||||||
|
await eintrag.getByRole('button', { name: 'Löschen' }).click()
|
||||||
|
await eintrag.getByRole('button', { name: 'In den Papierkorb' }).click()
|
||||||
|
await expect(page.getByText(`${VORLAGE} mit Absatz`)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.goto('/einstellungen/papierkorb')
|
||||||
|
const zeile = page.locator('li', { hasText: `${VORLAGE} mit Absatz` })
|
||||||
|
await expect(zeile).toBeVisible()
|
||||||
|
|
||||||
|
await zeile.getByRole('button', { name: 'Wiederherstellen' }).click()
|
||||||
|
await page.goto('/einstellungen/vorlagen')
|
||||||
|
await expect(page.getByText(`${VORLAGE} mit Absatz`)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "1.3.0",
|
"version": "1.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"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:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
|
||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
--
|
--
|
||||||
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
|
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
|
||||||
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
|
-- 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,
|
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
|
||||||
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
|
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
|
||||||
-- jeder erzeugten Migration entfernt.
|
-- jeder erzeugten Migration entfernt.
|
||||||
|
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
|
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
--
|
--
|
||||||
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
|
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
|
||||||
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
|
-- 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,
|
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
|
||||||
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
|
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
|
||||||
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
|
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
|
||||||
|
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "zahlungsgruppe_geklaert" (
|
CREATE TABLE "zahlungsgruppe_geklaert" (
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
--
|
--
|
||||||
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
|
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
|
||||||
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
|
-- 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
|
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
|
||||||
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
|
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
|
||||||
-- nachsehen.
|
-- nachsehen.
|
||||||
|
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
|
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Eigene Schreibvorlagen.
|
||||||
|
--
|
||||||
|
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
|
||||||
|
-- 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" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"betreuteId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"betreff" TEXT NOT NULL DEFAULT '',
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"ohneBestellungsabsatz" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "schreibvorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "schreibvorlage_betreuteId_idx" ON "schreibvorlage"("betreuteId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "schreibvorlage" ADD CONSTRAINT "schreibvorlage_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
|
||||||
|
--
|
||||||
|
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
|
||||||
|
-- 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');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "kontakt" ADD COLUMN "anrede" "KontaktAnrede" NOT NULL DEFAULT 'OHNE';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "vorgang" ADD COLUMN "ansprechpartnerId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "vorgang" ADD CONSTRAINT "vorgang_ansprechpartnerId_fkey" FOREIGN KEY ("ansprechpartnerId") REFERENCES "kontakt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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;
|
||||||
+64
-4
@@ -81,6 +81,13 @@ model User {
|
|||||||
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
|
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
|
||||||
sitzungsGeneration Int @default(0)
|
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
|
// Oberflaechen-Einstellungen
|
||||||
theme String @default("system") // "system" | "hell" | "dunkel"
|
theme String @default("system") // "system" | "hell" | "dunkel"
|
||||||
|
|
||||||
@@ -313,6 +320,7 @@ model Betreute {
|
|||||||
konten Konto[]
|
konten Konto[]
|
||||||
buchungen Buchung[]
|
buchungen Buchung[]
|
||||||
zahlungsgruppen ZahlungsgruppeGeklaert[]
|
zahlungsgruppen ZahlungsgruppeGeklaert[]
|
||||||
|
schreibvorlagen Schreibvorlage[]
|
||||||
vermoegensposten Vermoegensposten[]
|
vermoegensposten Vermoegensposten[]
|
||||||
immobilien Immobilie[]
|
immobilien Immobilie[]
|
||||||
vertraege Vertrag[]
|
vertraege Vertrag[]
|
||||||
@@ -372,6 +380,14 @@ model Vorgang {
|
|||||||
gegenstelle String?
|
gegenstelle String?
|
||||||
aktenzeichen String?
|
aktenzeichen String?
|
||||||
|
|
||||||
|
/// Die Person bei der Gegenstelle, die den Vorgang bearbeitet. Ein
|
||||||
|
/// namentlich adressiertes Schreiben landet bei ihr statt in der
|
||||||
|
/// Poststelle - beim Bezirk ist das der Unterschied zwischen zwei
|
||||||
|
/// Wochen und zwei Monaten. SetNull: Ein Vorgang darf nicht daran
|
||||||
|
/// haengen, dass es den Kontakt noch gibt.
|
||||||
|
ansprechpartnerId String?
|
||||||
|
ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
verantwortlich String?
|
verantwortlich String?
|
||||||
angelegtVonId String?
|
angelegtVonId String?
|
||||||
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
|
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
|
||||||
@@ -842,6 +858,39 @@ enum BuchungKategorie {
|
|||||||
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
|
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
|
||||||
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
|
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
|
||||||
/// zugeschnitten ist.
|
/// zugeschnitten ist.
|
||||||
|
/// Eine eigene Schreibvorlage.
|
||||||
|
///
|
||||||
|
/// Die mitgelieferten Vorlagen (Rueckkaufswert, Kuendigung,
|
||||||
|
/// Sachstandsanfrage) stehen bewusst NICHT in dieser Tabelle - sie sind
|
||||||
|
/// Code, getestet und unveraenderlich. "Wiederherstellen" heisst: die
|
||||||
|
/// mitgelieferte Fassung als neue Kopie hierher holen.
|
||||||
|
///
|
||||||
|
/// Der Text traegt Platzhalter im Format {{name}} - aufgeloest in
|
||||||
|
/// src/lib/schreiben/platzhalter.ts. Er ist bewusst NICHT verschluesselt:
|
||||||
|
/// Eine Vorlage ist ein Textbaustein ohne Fallbezug; die Fachdaten kommen
|
||||||
|
/// erst beim Erzeugen hinein.
|
||||||
|
model Schreibvorlage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
betreuteId String
|
||||||
|
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
betreff String @default("")
|
||||||
|
text String
|
||||||
|
|
||||||
|
/// Beim Speichern bestaetigt, dass der fehlende Bestellungsabsatz
|
||||||
|
/// Absicht ist - dann wird nicht bei jedem Bearbeiten erneut gewarnt.
|
||||||
|
ohneBestellungsabsatz Boolean @default(false)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
geloeschtAm DateTime?
|
||||||
|
|
||||||
|
@@index([betreuteId])
|
||||||
|
@@map("schreibvorlage")
|
||||||
|
}
|
||||||
|
|
||||||
model ZahlungsgruppeGeklaert {
|
model ZahlungsgruppeGeklaert {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@@ -1199,6 +1248,13 @@ model Vertrag {
|
|||||||
// Kontakte und Kontaktprotokoll
|
// Kontakte und Kontaktprotokoll
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Anrede fuer die Briefanrede eines Kontakts.
|
||||||
|
enum KontaktAnrede {
|
||||||
|
OHNE
|
||||||
|
FRAU
|
||||||
|
HERR
|
||||||
|
}
|
||||||
|
|
||||||
enum KontaktRolle {
|
enum KontaktRolle {
|
||||||
RECHTSPFLEGER
|
RECHTSPFLEGER
|
||||||
RICHTER
|
RICHTER
|
||||||
@@ -1233,7 +1289,10 @@ model Kontakt {
|
|||||||
|
|
||||||
name String
|
name String
|
||||||
organisation String?
|
organisation String?
|
||||||
rolle KontaktRolle @default(SONSTIGES)
|
rolle KontaktRolle @default(SONSTIGES)
|
||||||
|
/// Fuer die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet -
|
||||||
|
/// nie geraten, das faellt dem Empfaenger sofort auf.
|
||||||
|
anrede KontaktAnrede @default(OHNE)
|
||||||
|
|
||||||
telefon String?
|
telefon String?
|
||||||
mobil String?
|
mobil String?
|
||||||
@@ -1251,9 +1310,10 @@ model Kontakt {
|
|||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
geloeschtAm DateTime?
|
geloeschtAm DateTime?
|
||||||
|
|
||||||
protokolle Kontaktprotokoll[]
|
protokolle Kontaktprotokoll[]
|
||||||
vorgaenge Vorgang[] @relation("VorgangKontakte")
|
vorgaenge Vorgang[] @relation("VorgangKontakte")
|
||||||
verknuepfungen DokumentVerknuepfung[]
|
betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner")
|
||||||
|
verknuepfungen DokumentVerknuepfung[]
|
||||||
|
|
||||||
@@index([betreuteId, rolle])
|
@@index([betreuteId, rolle])
|
||||||
@@map("kontakt")
|
@@map("kontakt")
|
||||||
|
|||||||
+307
-2
@@ -22,7 +22,7 @@
|
|||||||
*
|
*
|
||||||
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
|
* 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 { hash } from '@node-rs/argon2'
|
||||||
import { createCipheriv, randomBytes } from 'node:crypto'
|
import { createCipheriv, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
@@ -263,9 +263,11 @@ async function demofall(userId: string) {
|
|||||||
art: 'GIRO',
|
art: 'GIRO',
|
||||||
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
|
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
|
||||||
saldostaende: {
|
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: [
|
create: [
|
||||||
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
|
{ 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.')
|
console.log('Demofall "Erika Musterfrau" angelegt.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -75,9 +75,19 @@ done
|
|||||||
# --- Selbstpruefung ----------------------------------------------------------
|
# --- Selbstpruefung ----------------------------------------------------------
|
||||||
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
|
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
|
||||||
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
|
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
|
||||||
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
# Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie
|
||||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
# genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt
|
||||||
| head -c 5 | grep -q "PGDMP"; then
|
# 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."
|
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
||||||
else
|
else
|
||||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
||||||
|
|||||||
+45
-5
@@ -52,6 +52,32 @@ gut() { printf ' %s%s%s\n' "$GRUEN" "$1" "$AUS"; }
|
|||||||
warnung() { printf ' %s%s%s\n' "$GELB" "$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; }
|
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 ---------------------------------------------------------
|
# --- Aufrufparameter ---------------------------------------------------------
|
||||||
|
|
||||||
ZIEL=""
|
ZIEL=""
|
||||||
@@ -113,7 +139,11 @@ if ! git fetch --tags --quiet origin; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
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)."
|
[ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)."
|
||||||
|
|
||||||
@@ -123,7 +153,8 @@ fi
|
|||||||
|
|
||||||
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|
||||||
|| abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden:
|
|| 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 ' Jetzt: %s\n' "$JETZT"
|
||||||
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
|
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
|
||||||
@@ -152,14 +183,23 @@ if [ -n "$CHANGELOG" ]; then
|
|||||||
if (nummer == jetzt) exit
|
if (nummer == jetzt) exit
|
||||||
drucken = (nummer != "Unveröffentlicht")
|
drucken = (nummer != "Unveröffentlicht")
|
||||||
}
|
}
|
||||||
drucken { print " " $0 }
|
drucken && zeilen < 60 { print " " $0; zeilen++ }
|
||||||
' | head -60
|
'
|
||||||
else
|
else
|
||||||
warnung "Kein CHANGELOG.md in $ZIEL gefunden."
|
warnung "Kein CHANGELOG.md in $ZIEL gefunden."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\n'
|
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
|
if [ "$NUR_PRUEFEN" = "1" ]; then
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
|||||||
+163
-163
@@ -1,163 +1,163 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { BerichtsListe } from '@/components/berichte/berichts-liste'
|
import { BerichtsListe } from '@/components/berichte/berichts-liste'
|
||||||
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
|
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
|
||||||
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
|
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
|
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
|
||||||
|
|
||||||
export const metadata = { title: 'Berichte' }
|
export const metadata = { title: 'Berichte' }
|
||||||
|
|
||||||
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
|
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
|
||||||
const BERICHTSFRISTEN = [
|
const BERICHTSFRISTEN = [
|
||||||
'anfangsbericht',
|
'anfangsbericht',
|
||||||
'jahresbericht',
|
'jahresbericht',
|
||||||
'vermoegensverzeichnis',
|
'vermoegensverzeichnis',
|
||||||
'vermoegensuebersicht',
|
'vermoegensuebersicht',
|
||||||
'rechnungslegung',
|
'rechnungslegung',
|
||||||
]
|
]
|
||||||
|
|
||||||
const WEGE = [
|
const WEGE = [
|
||||||
{
|
{
|
||||||
href: '/berichte/jahresbericht',
|
href: '/berichte/jahresbericht',
|
||||||
titel: 'Jahresbericht',
|
titel: 'Jahresbericht',
|
||||||
beschreibung:
|
beschreibung:
|
||||||
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
|
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/berichte/belegmappe',
|
href: '/berichte/belegmappe',
|
||||||
titel: 'Belegmappe',
|
titel: 'Belegmappe',
|
||||||
beschreibung:
|
beschreibung:
|
||||||
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
|
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
|
||||||
icon: FolderArchive,
|
icon: FolderArchive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/berichte/unterlagenpaket',
|
href: '/berichte/unterlagenpaket',
|
||||||
titel: 'Unterlagen für den Träger',
|
titel: 'Unterlagen für den Träger',
|
||||||
beschreibung:
|
beschreibung:
|
||||||
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
|
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
|
||||||
icon: Paperclip,
|
icon: Paperclip,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/berichte/export',
|
href: '/berichte/export',
|
||||||
titel: 'Vollexport',
|
titel: 'Vollexport',
|
||||||
beschreibung:
|
beschreibung:
|
||||||
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
|
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
|
||||||
icon: Archive,
|
icon: Archive,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Berichtsuebersicht beantwortet zwei Fragen:
|
* Die Berichtsuebersicht beantwortet zwei Fragen:
|
||||||
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
|
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
|
||||||
*
|
*
|
||||||
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
|
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
|
||||||
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
|
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
|
||||||
*/
|
*/
|
||||||
export default async function BerichteSeite() {
|
export default async function BerichteSeite() {
|
||||||
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
|
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
|
||||||
|
|
||||||
const [fristen, zeilen] = await Promise.all([
|
const [fristen, zeilen] = await Promise.all([
|
||||||
db.frist.findMany({
|
db.frist.findMany({
|
||||||
where: {
|
where: {
|
||||||
betreuteId,
|
betreuteId,
|
||||||
status: 'OFFEN',
|
status: 'OFFEN',
|
||||||
...nichtGeloescht,
|
...nichtGeloescht,
|
||||||
OR: BERICHTSFRISTEN.map((schluessel) => ({
|
OR: BERICHTSFRISTEN.map((schluessel) => ({
|
||||||
systemSchluessel: { startsWith: schluessel },
|
systemSchluessel: { startsWith: schluessel },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
orderBy: { datum: 'asc' },
|
orderBy: { datum: 'asc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
}),
|
}),
|
||||||
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
|
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SeitenKopf
|
<SeitenKopf
|
||||||
titel="Berichte"
|
titel="Berichte"
|
||||||
pfad={[{ titel: 'Berichte' }]}
|
pfad={[{ titel: 'Berichte' }]}
|
||||||
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
|
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="space-y-3" aria-label="Demnächst fällig">
|
<section className="space-y-3" aria-label="Demnächst fällig">
|
||||||
<h2 className="text-base font-semibold">Demnächst fällig</h2>
|
<h2 className="text-base font-semibold">Demnächst fällig</h2>
|
||||||
{fristen.length === 0 ? (
|
{fristen.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
|
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
|
||||||
<Link href="/fristen" className="underline underline-offset-4">
|
<Link href="/fristen" className="underline underline-offset-4">
|
||||||
Fristen
|
Fristen
|
||||||
</Link>
|
</Link>
|
||||||
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
|
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{fristen.map((frist) => {
|
{fristen.map((frist) => {
|
||||||
const tage = tageBis(frist.datum)
|
const tage = tageBis(frist.datum)
|
||||||
const variante =
|
const variante =
|
||||||
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
|
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
|
||||||
return (
|
return (
|
||||||
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
|
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
|
||||||
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block font-medium">{frist.titel}</span>
|
<span className="block font-medium">{frist.titel}</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDatum(frist.datum)}
|
{formatDatum(frist.datum)}
|
||||||
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
|
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
|
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
|
||||||
<Badge variant={variante}>
|
<Badge variant={variante}>
|
||||||
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
|
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
|
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
|
||||||
{WEGE.map((weg) => {
|
{WEGE.map((weg) => {
|
||||||
const Icon = weg.icon
|
const Icon = weg.icon
|
||||||
return (
|
return (
|
||||||
<Card key={weg.href}>
|
<Card key={weg.href}>
|
||||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
||||||
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">
|
||||||
<Link href={weg.href} className="underline-offset-4 hover:underline">
|
<Link href={weg.href} className="underline-offset-4 hover:underline">
|
||||||
{weg.titel}
|
{weg.titel}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
|
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3" aria-label="Erzeugte Berichte">
|
<section className="space-y-3" aria-label="Erzeugte Berichte">
|
||||||
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
|
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
|
||||||
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
|
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<HinweisKarten kontext={['BERICHTE']} />
|
<HinweisKarten kontext={['BERICHTE']} />
|
||||||
|
|
||||||
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
|
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { KeyRound, Trash2, Users } from 'lucide-react'
|
import { KeyRound, FileText, Trash2, Users } from 'lucide-react'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -69,6 +69,23 @@ export default async function EinstellungenSeite() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
||||||
|
<FileText className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
<Link href="/einstellungen/vorlagen" className="underline-offset-4 hover:underline">
|
||||||
|
Schreibvorlagen
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Bewährte Formulierungen mit Platzhaltern hinterlegen – beim Erzeugen eines
|
||||||
|
Schreibens wählbar.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
||||||
<Trash2 className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
<Trash2 className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { VorlagenVerwaltung } from '@/components/einstellungen/vorlagen-verwaltung'
|
||||||
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { MITGELIEFERTE_VORLAGEN } from '@/lib/schreiben/mitgelieferte-vorlagen'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Vorlagen' }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen → Vorlagen.
|
||||||
|
*
|
||||||
|
* Vorlagen hängen an der betreuten Person, nicht am Benutzer: Die
|
||||||
|
* Formulierungen beziehen sich auf ihren Fall, und beim Vollexport ihrer
|
||||||
|
* Akte gehören sie mit hinein.
|
||||||
|
*/
|
||||||
|
export default async function VorlagenSeite() {
|
||||||
|
const benutzer = await erfordereBenutzer('/einstellungen/vorlagen')
|
||||||
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
|
if (!aktive) redirect('/ersteinrichtung')
|
||||||
|
|
||||||
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
|
||||||
|
if (!darfSchreiben('DOKUMENTE')) redirect('/einstellungen')
|
||||||
|
|
||||||
|
const eigene = await db.schreibvorlage.findMany({
|
||||||
|
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, name: true, betreff: true, text: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Schreibvorlagen"
|
||||||
|
beschreibung="Bewährte Formulierungen einmal hinterlegen, beim Schreiben auswählen. Platzhalter füllen sich aus den Daten, die ohnehin im System stehen."
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Einstellungen', href: '/einstellungen' },
|
||||||
|
{ titel: 'Vorlagen' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VorlagenVerwaltung
|
||||||
|
betreuteId={aktive.id}
|
||||||
|
eigene={eigene}
|
||||||
|
mitgelieferte={[...MITGELIEFERTE_VORLAGEN]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
VERMOEGENS_ART_EINZAHL,
|
VERMOEGENS_ART_EINZAHL,
|
||||||
} from '@/lib/finanzen/kategorien'
|
} from '@/lib/finanzen/kategorien'
|
||||||
import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
|
import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
|
||||||
|
import { wertVeraltet } from '@/lib/finanzen/wertalter'
|
||||||
import type { SozialhilfeBewertung } from '@prisma/client'
|
import type { SozialhilfeBewertung } from '@prisma/client'
|
||||||
|
|
||||||
export const metadata = { title: 'Vermögen' }
|
export const metadata = { title: 'Vermögen' }
|
||||||
@@ -235,7 +236,7 @@ function Postentabelle({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{formatDatum(eintrag.bewertetAm)}
|
{formatDatum(eintrag.bewertetAm)}
|
||||||
{eintrag.alterTage > 365 && (
|
{wertVeraltet(eintrag.alterTage) && (
|
||||||
<Badge variant="gelb" className="ml-2">
|
<Badge variant="gelb" className="ml-2">
|
||||||
über ein Jahr alt
|
über ein Jahr alt
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({
|
|||||||
name: kontakt.name,
|
name: kontakt.name,
|
||||||
organisation: kontakt.organisation,
|
organisation: kontakt.organisation,
|
||||||
rolle: kontakt.rolle,
|
rolle: kontakt.rolle,
|
||||||
|
anrede: kontakt.anrede,
|
||||||
telefon: kontakt.telefon,
|
telefon: kontakt.telefon,
|
||||||
mobil: kontakt.mobil,
|
mobil: kontakt.mobil,
|
||||||
fax: kontakt.fax,
|
fax: kontakt.fax,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import Link from 'next/link'
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -11,6 +11,7 @@ import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
|
|||||||
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
|
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
|
||||||
import { formatDatumZeit } from '@/lib/utils'
|
import { formatDatumZeit } from '@/lib/utils'
|
||||||
import { PasswortFormular } from './passwort-formular'
|
import { PasswortFormular } from './passwort-formular'
|
||||||
|
import { AnschriftFormular } from './anschrift-formular'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Mein Konto' }
|
export const metadata: Metadata = { title: 'Mein Konto' }
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -22,7 +23,10 @@ export default async function KontoSeite() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -55,6 +59,27 @@ export default async function KontoSeite() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { briefDocxErzeugen } from '@/lib/schreiben/docx'
|
||||||
|
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Schreiben als Word-Datei herunterladen.
|
||||||
|
*
|
||||||
|
* Warum eine Route und keine Server Action? Eine Action kann keinen
|
||||||
|
* Download ausloesen - sie muesste die Datei kodiert zurueckgeben und der
|
||||||
|
* Browser sie nachbauen. Eine Route liefert sie unmittelbar aus, mit
|
||||||
|
* richtigem Namen und Typ. Denselben Weg geht schon der Vollexport.
|
||||||
|
*
|
||||||
|
* POST und nicht GET, weil der bearbeitete Brieftext mitkommt und in eine
|
||||||
|
* Adresszeile weder hineinpasst noch hineingehoert: Er stuende sonst im
|
||||||
|
* Verlauf des Browsers und in jedem Zwischenspeicher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const MIME_DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request): Promise<Response> {
|
||||||
|
let formular: FormData
|
||||||
|
try {
|
||||||
|
formular = await anfrage.formData()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||||
|
const rohArt = String(formular.get('art') ?? '')
|
||||||
|
const text = String(formular.get('text') ?? '')
|
||||||
|
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
|
||||||
|
const versandRoh = String(formular.get('versandAm') ?? '').trim()
|
||||||
|
|
||||||
|
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
|
||||||
|
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zugriff wird hier geprueft und nicht nur auf der Seite davor: Eine
|
||||||
|
// Route laesst sich unmittelbar aufrufen.
|
||||||
|
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
|
||||||
|
} catch (ausnahme) {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) {
|
||||||
|
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
|
||||||
|
}
|
||||||
|
throw ausnahme
|
||||||
|
}
|
||||||
|
|
||||||
|
const datum = tagAusText(versandRoh) ?? heuteAlsTag()
|
||||||
|
const datei = await briefDocxErzeugen(text)
|
||||||
|
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, datum, 'docx')
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(datei), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': MIME_DOCX,
|
||||||
|
'Content-Disposition': `attachment; filename="${dateiname}"`,
|
||||||
|
'Content-Length': String(datei.length),
|
||||||
|
// Ein Schreiben aus einer Betreuungsakte gehoert in keinen
|
||||||
|
// Zwischenspeicher.
|
||||||
|
'Cache-Control': 'no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { emailFassung, siehtAusWieEmail } from '@/lib/schreiben/email'
|
||||||
|
import { emlErzeugen } from '@/lib/schreiben/eml'
|
||||||
|
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Schreiben als .eml-Entwurf herunterladen.
|
||||||
|
*
|
||||||
|
* Gleicher Aufbau wie der Word-Download (../docx/route.ts): POST, weil der
|
||||||
|
* Text mitkommt und nicht in die Adresszeile gehoert; Zugriff wird hier
|
||||||
|
* geprueft, weil sich eine Route unmittelbar aufrufen laesst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request): Promise<Response> {
|
||||||
|
let formular: FormData
|
||||||
|
try {
|
||||||
|
formular = await anfrage.formData()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||||
|
const rohArt = String(formular.get('art') ?? '')
|
||||||
|
const text = String(formular.get('text') ?? '')
|
||||||
|
const betreff = String(formular.get('betreff') ?? '').trim()
|
||||||
|
const adresse = String(formular.get('empfaengerEmail') ?? '').trim()
|
||||||
|
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
|
||||||
|
|
||||||
|
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
|
||||||
|
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!siehtAusWieEmail(adresse)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Für diesen Empfänger ist keine E-Mail-Adresse hinterlegt.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
|
||||||
|
} catch (ausnahme) {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) {
|
||||||
|
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
|
||||||
|
}
|
||||||
|
throw ausnahme
|
||||||
|
}
|
||||||
|
|
||||||
|
const fassung = emailFassung(text, betreff)
|
||||||
|
const datei = emlErzeugen({ an: adresse, betreff: fassung.betreff, text: fassung.text })
|
||||||
|
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, heuteAlsTag(), 'eml')
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(datei), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'message/rfc822',
|
||||||
|
'Content-Disposition': `attachment; filename="${dateiname}"`,
|
||||||
|
'Content-Length': String(datei.length),
|
||||||
|
'Cache-Control': 'no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { AlertCircle, Scale } from 'lucide-react'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { SchreibenEditor } from '@/components/schreiben/schreiben-editor'
|
||||||
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { entwurfBauen } from '@/lib/schreiben/entwurf'
|
||||||
|
import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten'
|
||||||
|
import { VorlagenWahl } from '@/components/schreiben/vorlagen-wahl'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Schreiben' }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Schreiben erzeugen, bearbeiten, ausgeben und ablegen.
|
||||||
|
*
|
||||||
|
* Eine Seite für alle Arten und alle drei Empfängerwege — statt die
|
||||||
|
* Bearbeitung dreimal an Vertrag, Vorgang und Freitext nachzubauen. Die
|
||||||
|
* bestehenden Seiten verlinken hierher.
|
||||||
|
*
|
||||||
|
* Der Fall ohne Bezug ist ausdrücklich vorgesehen: Am Anfang einer
|
||||||
|
* Betreuung schreibt man an Stellen, zu denen es noch keinen Datensatz
|
||||||
|
* gibt — man fragt ja gerade an, um zu erfahren, ob dort etwas besteht.
|
||||||
|
*/
|
||||||
|
export default async function SchreibenSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
|
}) {
|
||||||
|
const parameter = await searchParams
|
||||||
|
const benutzer = await erfordereBenutzer('/schreiben/neu')
|
||||||
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
|
if (!aktive) redirect('/ersteinrichtung')
|
||||||
|
|
||||||
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
|
||||||
|
if (!darfSchreiben('DOKUMENTE')) redirect('/dokumente')
|
||||||
|
|
||||||
|
const text = (name: string): string => {
|
||||||
|
const wert = parameter[name]
|
||||||
|
return typeof wert === 'string' ? wert.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const rohArt = text('art')
|
||||||
|
const art = istSchreibenArt(rohArt) ? rohArt : 'FREI'
|
||||||
|
const eigenschaften = SCHREIBEN_EIGENSCHAFTEN[art]
|
||||||
|
|
||||||
|
let entwurf
|
||||||
|
try {
|
||||||
|
entwurf = await entwurfBauen({
|
||||||
|
betreuteId: aktive.id,
|
||||||
|
benutzer,
|
||||||
|
art,
|
||||||
|
vertragId: text('vertragId') || null,
|
||||||
|
vorgangId: text('vorgangId') || null,
|
||||||
|
vorlageId: text('vorlageId') || null,
|
||||||
|
})
|
||||||
|
} catch (ausnahme) {
|
||||||
|
// Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine
|
||||||
|
// Angabe, die nicht stimmt. Die Seite sagt es und bietet den freien
|
||||||
|
// Weg an, statt den Benutzer vor eine Fehlerseite zu setzen.
|
||||||
|
const meldung =
|
||||||
|
ausnahme instanceof Error ? ausnahme.message : 'Der Entwurf ließ sich nicht bauen.'
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf titel="Schreiben" beschreibung={eigenschaften.beschriftung} />
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
{meldung} Du kannst stattdessen ein{' '}
|
||||||
|
<a href="/schreiben/neu?art=FREI" className="underline">
|
||||||
|
freies Schreiben
|
||||||
|
</a>{' '}
|
||||||
|
erzeugen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorlagen =
|
||||||
|
art === 'FREI'
|
||||||
|
? await db.schreibvorlage.findMany({
|
||||||
|
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const kontakte = await db.kontakt.findMany({
|
||||||
|
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
organisation: true,
|
||||||
|
strasse: true,
|
||||||
|
plz: true,
|
||||||
|
ort: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel={eigenschaften.beschriftung}
|
||||||
|
beschreibung="Bearbeiten, als Word-Datei herunterladen, in die Akte legen — und eine Wiedervorlage setzen, damit das Warten nicht vergessen geht."
|
||||||
|
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{art === 'FREI' && vorlagen.length > 0 && (
|
||||||
|
<VorlagenWahl vorlagen={vorlagen} aktiveVorlageId={text('vorlageId') || null} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SchreibenEditor
|
||||||
|
// Der Editor haelt den Text als eigenen State. Wechselt die Vorlage,
|
||||||
|
// muss er neu aufsetzen - sonst ueberlebt der alte Entwurf die
|
||||||
|
// Navigation und die Vorlage kommt nie im Textfeld an.
|
||||||
|
key={text('vorlageId') || 'ohne-vorlage'}
|
||||||
|
betreuteId={aktive.id}
|
||||||
|
art={art}
|
||||||
|
entwurf={{
|
||||||
|
betreff: entwurf.betreff,
|
||||||
|
text: entwurf.text,
|
||||||
|
empfaengerName: entwurf.empfaengerName,
|
||||||
|
empfaengerAnschrift: entwurf.empfaengerAnschrift,
|
||||||
|
wiedervorlageAm: entwurf.wiedervorlageAm,
|
||||||
|
}}
|
||||||
|
kontakte={kontakte}
|
||||||
|
vertragId={entwurf.vertragId}
|
||||||
|
vorgangId={entwurf.vorgangId}
|
||||||
|
fristBegruendung={eigenschaften.fristBegruendung}
|
||||||
|
warnungen={entwurf.warnungen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<Scale aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Der vorgeschlagene Wortlaut ist ein Entwurf, keine Rechtsberatung. Bei einem
|
||||||
|
genehmigungspflichtigen Geschäft gehört die Genehmigung des Betreuungsgerichts vor den
|
||||||
|
Versand — nicht danach.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,149 +1,149 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
|
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
|
||||||
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
|
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
|
||||||
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
|
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
|
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
|
||||||
import { betragFormatieren } from '@/lib/finanzen/betrag'
|
import { betragFormatieren } from '@/lib/finanzen/betrag'
|
||||||
import {
|
import {
|
||||||
fristAmpel,
|
fristAmpel,
|
||||||
rechtsbehelfsfristBerechnen,
|
rechtsbehelfsfristBerechnen,
|
||||||
zugangsvermutung,
|
zugangsvermutung,
|
||||||
} from '@/lib/sozialhilfe/rechtsbehelf'
|
} from '@/lib/sozialhilfe/rechtsbehelf'
|
||||||
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
|
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
|
||||||
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
|
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
|
||||||
import { formatDatum } from '@/lib/utils'
|
import { formatDatum } from '@/lib/utils'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bescheid-Prüfung.
|
* Bescheid-Prüfung.
|
||||||
*
|
*
|
||||||
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
|
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
|
||||||
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
|
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
|
||||||
* können, wo die Rechnung abgebogen ist.
|
* können, wo die Rechnung abgebogen ist.
|
||||||
*/
|
*/
|
||||||
export default async function BescheidSeite({
|
export default async function BescheidSeite({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ antragId: string; bescheidId: string }>
|
params: Promise<{ antragId: string; bescheidId: string }>
|
||||||
}) {
|
}) {
|
||||||
const { antragId, bescheidId } = await params
|
const { antragId, bescheidId } = await params
|
||||||
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
|
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
|
||||||
|
|
||||||
const bescheid = await db.bescheid.findFirst({
|
const bescheid = await db.bescheid.findFirst({
|
||||||
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
|
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
|
||||||
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
|
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
|
||||||
})
|
})
|
||||||
if (!bescheid) notFound()
|
if (!bescheid) notFound()
|
||||||
|
|
||||||
const offen = oeffnen('Bescheid', bescheid)
|
const offen = oeffnen('Bescheid', bescheid)
|
||||||
const heute = heuteAlsTag()
|
const heute = heuteAlsTag()
|
||||||
|
|
||||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||||
zugegangenAm: bescheid.zugegangenAm,
|
zugegangenAm: bescheid.zugegangenAm,
|
||||||
bescheidVom: bescheid.bescheidVom,
|
bescheidVom: bescheid.bescheidVom,
|
||||||
fristMonate: bescheid.rechtsbehelfsfristMonate,
|
fristMonate: bescheid.rechtsbehelfsfristMonate,
|
||||||
heute,
|
heute,
|
||||||
})
|
})
|
||||||
|
|
||||||
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
|
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SeitenKopf
|
<SeitenKopf
|
||||||
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
|
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
|
||||||
beschreibung={
|
beschreibung={
|
||||||
<>
|
<>
|
||||||
{bescheid.antrag.traeger}
|
{bescheid.antrag.traeger}
|
||||||
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
|
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
|
||||||
{formatDatum(bescheid.zugegangenAm)}
|
{formatDatum(bescheid.zugegangenAm)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
pfad={[
|
pfad={[
|
||||||
{ titel: 'Start', href: '/heute' },
|
{ titel: 'Start', href: '/heute' },
|
||||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||||
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
|
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
|
||||||
{ titel: 'Bescheid' },
|
{ titel: 'Bescheid' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BescheidPruefung
|
<BescheidPruefung
|
||||||
bescheidId={bescheid.id}
|
bescheidId={bescheid.id}
|
||||||
pruefStatus={bescheid.pruefStatus}
|
pruefStatus={bescheid.pruefStatus}
|
||||||
widerspruchEingelegtAm={
|
widerspruchEingelegtAm={
|
||||||
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
|
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
|
||||||
}
|
}
|
||||||
darfSchreiben={darfSchreiben}
|
darfSchreiben={darfSchreiben}
|
||||||
rechenweg={{
|
rechenweg={{
|
||||||
berechenbar: ergebnis.berechenbar,
|
berechenbar: ergebnis.berechenbar,
|
||||||
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
|
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
|
||||||
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
|
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
|
||||||
: null,
|
: null,
|
||||||
rechnerischesEnde: ergebnis.rechnerischesEnde
|
rechnerischesEnde: ergebnis.rechnerischesEnde
|
||||||
? alsIsoTag(ergebnis.rechnerischesEnde)
|
? alsIsoTag(ergebnis.rechnerischesEnde)
|
||||||
: null,
|
: null,
|
||||||
verschoben: ergebnis.verschoben,
|
verschoben: ergebnis.verschoben,
|
||||||
tageVerbleibend: ergebnis.tageVerbleibend,
|
tageVerbleibend: ergebnis.tageVerbleibend,
|
||||||
abgelaufen: ergebnis.abgelaufen,
|
abgelaufen: ergebnis.abgelaufen,
|
||||||
ampel: fristAmpel(ergebnis),
|
ampel: fristAmpel(ergebnis),
|
||||||
schritte: ergebnis.schritte,
|
schritte: ergebnis.schritte,
|
||||||
hinweise: ergebnis.hinweise,
|
hinweise: ergebnis.hinweise,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{offen.notizen && (
|
{offen.notizen && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notizen</CardTitle>
|
<CardTitle>Notizen</CardTitle>
|
||||||
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
|
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{darfSchreiben && (
|
{darfSchreiben && (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
|
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
|
||||||
<BescheidFormular
|
<BescheidFormular
|
||||||
antragId={antragId}
|
antragId={antragId}
|
||||||
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
|
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
|
||||||
werte={{
|
werte={{
|
||||||
id: bescheid.id,
|
id: bescheid.id,
|
||||||
art: bescheid.art,
|
art: bescheid.art,
|
||||||
bescheidVom: alsIsoTag(bescheid.bescheidVom),
|
bescheidVom: alsIsoTag(bescheid.bescheidVom),
|
||||||
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
|
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
|
||||||
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
|
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
|
||||||
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
|
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
|
||||||
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
|
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
|
||||||
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
|
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
|
||||||
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
|
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
|
||||||
? alsIsoTag(bescheid.widerspruchEingelegtAm)
|
? alsIsoTag(bescheid.widerspruchEingelegtAm)
|
||||||
: '',
|
: '',
|
||||||
notizen: offen.notizen ?? '',
|
notizen: offen.notizen ?? '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
<AktionMitRueckfrage
|
<AktionMitRueckfrage
|
||||||
aktion={bescheidLoeschen.bind(null, bescheid.id)}
|
aktion={bescheidLoeschen.bind(null, bescheid.id)}
|
||||||
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
|
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden="true" />
|
<Trash2 aria-hidden="true" />
|
||||||
Bescheid löschen
|
Bescheid löschen
|
||||||
</AktionMitRueckfrage>
|
</AktionMitRueckfrage>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,232 +1,232 @@
|
|||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
|
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
||||||
import { alsIsoTag } from '@/lib/fristen/datum'
|
import { alsIsoTag } from '@/lib/fristen/datum'
|
||||||
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
||||||
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||||
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
|
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
|
||||||
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
|
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
|
||||||
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
|
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
|
||||||
import { formatCent } from '@/lib/utils'
|
import { formatCent } from '@/lib/utils'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vermögens-Bewertungsansicht.
|
* Vermögens-Bewertungsansicht.
|
||||||
*
|
*
|
||||||
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
|
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
|
||||||
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
|
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
|
||||||
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
|
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
|
||||||
*/
|
*/
|
||||||
export default async function VermoegensbewertungSeite() {
|
export default async function VermoegensbewertungSeite() {
|
||||||
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
|
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
|
||||||
|
|
||||||
const [posten, vertraege] = await Promise.all([
|
const [posten, vertraege] = await Promise.all([
|
||||||
db.vermoegensposten.findMany({
|
db.vermoegensposten.findMany({
|
||||||
where: { betreuteId, ...nichtGeloescht },
|
where: { betreuteId, ...nichtGeloescht },
|
||||||
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
|
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
|
||||||
orderBy: { bezeichnung: 'asc' },
|
orderBy: { bezeichnung: 'asc' },
|
||||||
}),
|
}),
|
||||||
db.vertrag.findMany({
|
db.vertrag.findMany({
|
||||||
where: {
|
where: {
|
||||||
betreuteId,
|
betreuteId,
|
||||||
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
|
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
|
||||||
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
|
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
|
||||||
art: { in: [...MIT_RUECKKAUFSWERT] },
|
art: { in: [...MIT_RUECKKAUFSWERT] },
|
||||||
...nichtGeloescht,
|
...nichtGeloescht,
|
||||||
},
|
},
|
||||||
orderBy: { bezeichnung: 'asc' },
|
orderBy: { bezeichnung: 'asc' },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
|
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
|
||||||
// zeigen - sonst wäre die Summe zweimal drin.
|
// zeigen - sonst wäre die Summe zweimal drin.
|
||||||
const bereitsAlsPosten = new Set(
|
const bereitsAlsPosten = new Set(
|
||||||
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
|
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
|
||||||
)
|
)
|
||||||
|
|
||||||
const zeilen: ListenPosten[] = [
|
const zeilen: ListenPosten[] = [
|
||||||
...posten.map((p) => {
|
...posten.map((p) => {
|
||||||
const geoeffnet = oeffnen('Vermoegensposten', p)
|
const geoeffnet = oeffnen('Vermoegensposten', p)
|
||||||
const stand = p.wertstaende[0]
|
const stand = p.wertstaende[0]
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
bezeichnung: p.bezeichnung,
|
bezeichnung: p.bezeichnung,
|
||||||
herkunft: 'VERMOEGEN' as const,
|
herkunft: 'VERMOEGEN' as const,
|
||||||
art: p.art,
|
art: p.art,
|
||||||
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
|
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
|
||||||
bewertung: p.bewertungSozialhilfe,
|
bewertung: p.bewertungSozialhilfe,
|
||||||
wertCent: stand ? centZuZahl(stand.wertCent) : null,
|
wertCent: stand ? centZuZahl(stand.wertCent) : null,
|
||||||
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
|
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
|
||||||
begruendung: geoeffnet.bewertungBegruendung ?? '',
|
begruendung: geoeffnet.bewertungBegruendung ?? '',
|
||||||
zusatz: stand ? stand.wertquelle : null,
|
zusatz: stand ? stand.wertquelle : null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...vertraege
|
...vertraege
|
||||||
.filter((v) => !bereitsAlsPosten.has(v.id))
|
.filter((v) => !bereitsAlsPosten.has(v.id))
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const geoeffnet = oeffnen('Vertrag', v)
|
const geoeffnet = oeffnen('Vertrag', v)
|
||||||
return {
|
return {
|
||||||
id: v.id,
|
id: v.id,
|
||||||
bezeichnung: v.bezeichnung,
|
bezeichnung: v.bezeichnung,
|
||||||
herkunft: 'VERTRAG' as const,
|
herkunft: 'VERTRAG' as const,
|
||||||
art: v.art,
|
art: v.art,
|
||||||
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
|
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
|
||||||
bewertung: v.bewertungSozialhilfe,
|
bewertung: v.bewertungSozialhilfe,
|
||||||
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
|
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
|
||||||
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
|
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
|
||||||
wertCent: null,
|
wertCent: null,
|
||||||
wertStichtag: null,
|
wertStichtag: null,
|
||||||
begruendung: geoeffnet.bewertungBegruendung ?? '',
|
begruendung: geoeffnet.bewertungBegruendung ?? '',
|
||||||
zusatz: v.gesellschaft,
|
zusatz: v.gesellschaft,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const uebersicht = uebersichtBilden(
|
const uebersicht = uebersichtBilden(
|
||||||
zeilen.map((z) => ({
|
zeilen.map((z) => ({
|
||||||
id: z.id,
|
id: z.id,
|
||||||
bezeichnung: z.bezeichnung,
|
bezeichnung: z.bezeichnung,
|
||||||
herkunft: z.herkunft,
|
herkunft: z.herkunft,
|
||||||
artBeschriftung: z.artBeschriftung,
|
artBeschriftung: z.artBeschriftung,
|
||||||
bewertung: z.bewertung,
|
bewertung: z.bewertung,
|
||||||
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
|
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
|
||||||
wertStichtag: null,
|
wertStichtag: null,
|
||||||
begruendung: z.begruendung,
|
begruendung: z.begruendung,
|
||||||
href: '#',
|
href: '#',
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
|
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SeitenKopf
|
<SeitenKopf
|
||||||
titel="Vermögen bewerten"
|
titel="Vermögen bewerten"
|
||||||
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
|
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
|
||||||
pfad={[
|
pfad={[
|
||||||
{ titel: 'Start', href: '/heute' },
|
{ titel: 'Start', href: '/heute' },
|
||||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||||
{ titel: 'Vermögen' },
|
{ titel: 'Vermögen' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Summenkachel
|
<Summenkachel
|
||||||
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
|
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
|
||||||
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
|
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
|
||||||
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
|
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
|
||||||
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
|
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
|
||||||
farbe="text-ampel-rot"
|
farbe="text-ampel-rot"
|
||||||
/>
|
/>
|
||||||
<Summenkachel
|
<Summenkachel
|
||||||
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
|
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
|
||||||
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
|
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
|
||||||
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
|
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
|
||||||
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
|
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
|
||||||
farbe="text-ampel-gruen"
|
farbe="text-ampel-gruen"
|
||||||
/>
|
/>
|
||||||
<Summenkachel
|
<Summenkachel
|
||||||
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
|
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
|
||||||
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
|
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
|
||||||
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
|
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
|
||||||
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
|
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
|
||||||
farbe="text-ampel-orange"
|
farbe="text-ampel-orange"
|
||||||
/>
|
/>
|
||||||
<Summenkachel
|
<Summenkachel
|
||||||
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
|
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
|
||||||
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
|
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
|
||||||
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
|
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
|
||||||
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
|
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
|
||||||
farbe="text-muted-foreground"
|
farbe="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-2 pt-5">
|
<CardContent className="space-y-2 pt-5">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
|
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-semibold tabular-nums">
|
<span className="text-lg font-semibold tabular-nums">
|
||||||
{formatCent(vergleich.freibetragCent)}
|
{formatCent(vergleich.freibetragCent)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
|
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
|
||||||
<span
|
<span
|
||||||
className={`text-lg font-semibold tabular-nums ${
|
className={`text-lg font-semibold tabular-nums ${
|
||||||
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
|
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatCent(vergleich.ueberFreibetragCent)}
|
{formatCent(vergleich.ueberFreibetragCent)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
|
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
|
||||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
|
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{uebersicht.ungeprueft.length > 0 && (
|
{uebersicht.ungeprueft.length > 0 && (
|
||||||
<Alert variant="warnung">
|
<Alert variant="warnung">
|
||||||
<AlertCircle aria-hidden="true" />
|
<AlertCircle aria-hidden="true" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{uebersicht.ungeprueft.length}{' '}
|
{uebersicht.ungeprueft.length}{' '}
|
||||||
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
|
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
|
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
|
||||||
kostet Wochen.
|
kostet Wochen.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uebersicht.ohneBegruendung.length > 0 && (
|
{uebersicht.ohneBegruendung.length > 0 && (
|
||||||
<Alert variant="warnung">
|
<Alert variant="warnung">
|
||||||
<AlertCircle aria-hidden="true" />
|
<AlertCircle aria-hidden="true" />
|
||||||
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
|
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
|
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
|
||||||
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
|
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
|
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
|
||||||
|
|
||||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
|
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Summenkachel({
|
function Summenkachel({
|
||||||
titel,
|
titel,
|
||||||
betrag,
|
betrag,
|
||||||
anzahl,
|
anzahl,
|
||||||
ohneWert,
|
ohneWert,
|
||||||
farbe,
|
farbe,
|
||||||
}: {
|
}: {
|
||||||
titel: string
|
titel: string
|
||||||
betrag: bigint
|
betrag: bigint
|
||||||
anzahl: number
|
anzahl: number
|
||||||
ohneWert: number
|
ohneWert: number
|
||||||
farbe: string
|
farbe: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5">
|
<CardContent className="pt-5">
|
||||||
<p className="text-sm text-muted-foreground">{titel}</p>
|
<p className="text-sm text-muted-foreground">{titel}</p>
|
||||||
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
|
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
|
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
|
||||||
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
|
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,77 @@
|
|||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { VertragFormular } from '@/components/vertraege/vertrag-formular'
|
import { VertragFormular } from '@/components/vertraege/vertrag-formular'
|
||||||
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { alsIsoTag } from '@/lib/fristen/datum'
|
import { alsIsoTag } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/** Cent als Eingabetext: 481255 wird zu "4812,55". */
|
/** Cent als Eingabetext: 481255 wird zu "4812,55". */
|
||||||
function centAlsText(cent: bigint | null): string {
|
function centAlsText(cent: bigint | null): string {
|
||||||
if (cent === null) return ''
|
if (cent === null) return ''
|
||||||
const negativ = cent < 0n
|
const negativ = cent < 0n
|
||||||
const betrag = negativ ? -cent : cent
|
const betrag = negativ ? -cent : cent
|
||||||
const euro = betrag / 100n
|
const euro = betrag / 100n
|
||||||
const rest = betrag % 100n
|
const rest = betrag % 100n
|
||||||
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
|
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function VertragBearbeiten({
|
export default async function VertragBearbeiten({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
|
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
|
||||||
const aktive = await aktiveBetreute(benutzer)
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
if (!aktive) return null
|
if (!aktive) return null
|
||||||
|
|
||||||
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
||||||
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
|
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
|
||||||
|
|
||||||
const vertrag = await db.vertrag.findFirst({
|
const vertrag = await db.vertrag.findFirst({
|
||||||
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
})
|
})
|
||||||
if (!vertrag) notFound()
|
if (!vertrag) notFound()
|
||||||
|
|
||||||
const offen = oeffnen('Vertrag', vertrag)
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SeitenKopf
|
<SeitenKopf
|
||||||
titel={`${vertrag.bezeichnung} bearbeiten`}
|
titel={`${vertrag.bezeichnung} bearbeiten`}
|
||||||
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
|
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
|
||||||
pfad={[
|
pfad={[
|
||||||
{ titel: 'Übersicht', href: '/heute' },
|
{ titel: 'Übersicht', href: '/heute' },
|
||||||
{ titel: 'Verträge', href: '/vertraege' },
|
{ titel: 'Verträge', href: '/vertraege' },
|
||||||
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
|
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
|
||||||
{ titel: 'Bearbeiten' },
|
{ titel: 'Bearbeiten' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VertragFormular
|
<VertragFormular
|
||||||
betreuteId={aktive.id}
|
betreuteId={aktive.id}
|
||||||
werte={{
|
werte={{
|
||||||
id: vertrag.id,
|
id: vertrag.id,
|
||||||
bezeichnung: vertrag.bezeichnung,
|
bezeichnung: vertrag.bezeichnung,
|
||||||
gesellschaft: vertrag.gesellschaft ?? '',
|
gesellschaft: vertrag.gesellschaft ?? '',
|
||||||
art: vertrag.art,
|
art: vertrag.art,
|
||||||
status: vertrag.status,
|
status: vertrag.status,
|
||||||
vertragsnummer: offen.vertragsnummer ?? '',
|
vertragsnummer: offen.vertragsnummer ?? '',
|
||||||
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
|
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
|
||||||
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
|
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
|
||||||
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
|
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
|
||||||
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
|
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
|
||||||
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
|
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
|
||||||
beitrag: centAlsText(vertrag.beitragCent),
|
beitrag: centAlsText(vertrag.beitragCent),
|
||||||
rhythmus: vertrag.rhythmus ?? '',
|
rhythmus: vertrag.rhythmus ?? '',
|
||||||
bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
|
bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
|
||||||
bewertungBegruendung: offen.bewertungBegruendung ?? '',
|
bewertungBegruendung: offen.bewertungBegruendung ?? '',
|
||||||
notizen: offen.notizen ?? '',
|
notizen: offen.notizen ?? '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,96 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
|
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
|
||||||
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
|
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
|
||||||
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
|
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
|
||||||
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
|
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
|
||||||
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function VertragKuendigen({
|
export default async function VertragKuendigen({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
|
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
|
||||||
const aktive = await aktiveBetreute(benutzer)
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
if (!aktive) return null
|
if (!aktive) return null
|
||||||
|
|
||||||
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
||||||
const schreiben = darfSchreiben('VERTRAEGE')
|
const schreiben = darfSchreiben('VERTRAEGE')
|
||||||
|
|
||||||
const vertrag = await db.vertrag.findFirst({
|
const vertrag = await db.vertrag.findFirst({
|
||||||
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
})
|
})
|
||||||
if (!vertrag) notFound()
|
if (!vertrag) notFound()
|
||||||
|
|
||||||
const offen = oeffnen('Vertrag', vertrag)
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
const heute = heuteAlsTag()
|
const heute = heuteAlsTag()
|
||||||
|
|
||||||
const paket = kuendigungsassistent({
|
const paket = kuendigungsassistent({
|
||||||
kopf: briefkopfBauen({ betreute: aktive, benutzer }),
|
kopf: briefkopfBauen({ betreute: aktive, benutzer }),
|
||||||
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
|
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
|
||||||
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
|
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
|
||||||
vertragsnummer: offen.vertragsnummer,
|
vertragsnummer: offen.vertragsnummer,
|
||||||
art: vertrag.art,
|
art: vertrag.art,
|
||||||
laufzeit: {
|
laufzeit: {
|
||||||
beginnAm: vertrag.beginnAm,
|
beginnAm: vertrag.beginnAm,
|
||||||
endeAm: vertrag.endeAm,
|
endeAm: vertrag.endeAm,
|
||||||
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
||||||
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
||||||
},
|
},
|
||||||
heute,
|
heute,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SeitenKopf
|
<SeitenKopf
|
||||||
titel="Kündigungs-Assistent"
|
titel="Kündigungs-Assistent"
|
||||||
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
|
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
|
||||||
pfad={[
|
pfad={[
|
||||||
{ titel: 'Übersicht', href: '/heute' },
|
{ titel: 'Übersicht', href: '/heute' },
|
||||||
{ titel: 'Verträge', href: '/vertraege' },
|
{ titel: 'Verträge', href: '/vertraege' },
|
||||||
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
|
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
|
||||||
{ titel: 'Kündigen' },
|
{ titel: 'Kündigen' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KuendigungsAssistent
|
<KuendigungsAssistent
|
||||||
betreuteId={aktive.id}
|
betreuteId={aktive.id}
|
||||||
vertragId={id}
|
vertragId={id}
|
||||||
darfSchreiben={schreiben}
|
darfSchreiben={schreiben}
|
||||||
heuteIso={alsIsoTag(heute)}
|
heuteIso={alsIsoTag(heute)}
|
||||||
daten={{
|
daten={{
|
||||||
schreiben: paket.schreiben,
|
schreiben: paket.schreiben,
|
||||||
warnungen: paket.warnungen,
|
warnungen: paket.warnungen,
|
||||||
genehmigung: paket.genehmigung,
|
genehmigung: paket.genehmigung,
|
||||||
versandnachweis: {
|
versandnachweis: {
|
||||||
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
|
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
|
||||||
hinweis: paket.versandnachweis.hinweis,
|
hinweis: paket.versandnachweis.hinweis,
|
||||||
},
|
},
|
||||||
plan: {
|
plan: {
|
||||||
berechenbar: paket.plan.berechenbar,
|
berechenbar: paket.plan.berechenbar,
|
||||||
naechsterTermin: paket.plan.naechsterTermin
|
naechsterTermin: paket.plan.naechsterTermin
|
||||||
? alsIsoTag(paket.plan.naechsterTermin)
|
? alsIsoTag(paket.plan.naechsterTermin)
|
||||||
: null,
|
: null,
|
||||||
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
|
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
|
||||||
? alsIsoTag(paket.plan.zugangSpaetestensAm)
|
? alsIsoTag(paket.plan.zugangSpaetestensAm)
|
||||||
: null,
|
: null,
|
||||||
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
|
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
|
||||||
? alsIsoTag(paket.plan.spaetesterAbsendetag)
|
? alsIsoTag(paket.plan.spaetesterAbsendetag)
|
||||||
: null,
|
: null,
|
||||||
tageBisAbsendetag: paket.plan.tageBisAbsendetag,
|
tageBisAbsendetag: paket.plan.tageBisAbsendetag,
|
||||||
verpasst: paket.plan.verpasst,
|
verpasst: paket.plan.verpasst,
|
||||||
hinweise: paket.plan.hinweise,
|
hinweise: paket.plan.hinweise,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,266 +1,275 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { FileX2, Pencil, Shield } from 'lucide-react'
|
import { FileX2, Pencil, Shield } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Datenzeile } from '@/components/substanz-formular'
|
import { Datenzeile } from '@/components/substanz-formular'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
|
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
|
||||||
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
|
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
|
||||||
import {
|
import {
|
||||||
kuendigungsampel,
|
kuendigungsampel,
|
||||||
kuendigungsplan,
|
kuendigungsplan,
|
||||||
laufzeitAusVertrag,
|
laufzeitAusVertrag,
|
||||||
} from '@/lib/vertraege/kuendigung'
|
} from '@/lib/vertraege/kuendigung'
|
||||||
import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert'
|
import {
|
||||||
import {
|
anfrageStatus,
|
||||||
BEWERTUNG_BESCHRIFTUNG,
|
rueckkaufswertAnschreiben,
|
||||||
BEWERTUNG_ERKLAERUNG,
|
wertHistorie,
|
||||||
jahresbeitragCent,
|
} from '@/lib/vertraege/rueckkaufswert'
|
||||||
kannRueckkaufswertHaben,
|
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
|
||||||
RHYTHMUS_BESCHRIFTUNG,
|
import {
|
||||||
VERTRAG_ART_BESCHRIFTUNG,
|
BEWERTUNG_BESCHRIFTUNG,
|
||||||
VERTRAG_STATUS_BESCHRIFTUNG,
|
BEWERTUNG_ERKLAERUNG,
|
||||||
} from '@/lib/vertraege/konstanten'
|
jahresbeitragCent,
|
||||||
import { formatCent, formatDatum } from '@/lib/utils'
|
kannRueckkaufswertHaben,
|
||||||
|
RHYTHMUS_BESCHRIFTUNG,
|
||||||
export const dynamic = 'force-dynamic'
|
VERTRAG_ART_BESCHRIFTUNG,
|
||||||
|
VERTRAG_STATUS_BESCHRIFTUNG,
|
||||||
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
|
} from '@/lib/vertraege/konstanten'
|
||||||
const { id } = await params
|
import { formatCent, formatDatum } from '@/lib/utils'
|
||||||
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
|
|
||||||
const aktive = await aktiveBetreute(benutzer)
|
export const dynamic = 'force-dynamic'
|
||||||
if (!aktive) return null
|
|
||||||
|
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
const { id } = await params
|
||||||
const schreiben = darfSchreiben('VERTRAEGE')
|
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
|
||||||
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
const vertrag = await db.vertrag.findFirst({
|
if (!aktive) return null
|
||||||
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
|
||||||
include: {
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
||||||
vermoegensposten: {
|
const schreiben = darfSchreiben('VERTRAEGE')
|
||||||
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
|
|
||||||
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
|
const vertrag = await db.vertrag.findFirst({
|
||||||
},
|
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
},
|
include: {
|
||||||
})
|
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
|
||||||
if (!vertrag) notFound()
|
vermoegensposten: {
|
||||||
|
where: nichtGeloescht,
|
||||||
const offen = oeffnen('Vertrag', vertrag)
|
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
|
||||||
const heute = heuteAlsTag()
|
},
|
||||||
|
},
|
||||||
const plan = kuendigungsplan({
|
})
|
||||||
beginnAm: vertrag.beginnAm,
|
if (!vertrag) notFound()
|
||||||
endeAm: vertrag.endeAm,
|
|
||||||
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
const heute = heuteAlsTag()
|
||||||
heute,
|
|
||||||
})
|
const plan = kuendigungsplan({
|
||||||
const ampel = kuendigungsampel(plan)
|
beginnAm: vertrag.beginnAm,
|
||||||
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
|
endeAm: vertrag.endeAm,
|
||||||
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
|
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
||||||
|
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
||||||
const kopf = briefkopfBauen({ betreute: aktive, benutzer })
|
heute,
|
||||||
const anschreiben = rueckkaufswertAnschreiben({
|
})
|
||||||
kopf,
|
const ampel = kuendigungsampel(plan)
|
||||||
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
|
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
|
||||||
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
|
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
|
||||||
vertragsnummer: offen.vertragsnummer,
|
|
||||||
stichtag: heute,
|
const kopf = briefkopfBauen({ betreute: aktive, benutzer })
|
||||||
heute,
|
const anschreiben = rueckkaufswertAnschreiben({
|
||||||
})
|
kopf,
|
||||||
|
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
|
||||||
const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? []
|
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
|
||||||
|
vertragsnummer: offen.vertragsnummer,
|
||||||
return (
|
stichtag: heute,
|
||||||
<div className="space-y-6">
|
heute,
|
||||||
<SeitenKopf
|
})
|
||||||
titel={vertrag.bezeichnung}
|
|
||||||
beschreibung={
|
const wertstaende = wertHistorie(vertrag.vermoegensposten)
|
||||||
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
|
|
||||||
.filter(Boolean)
|
return (
|
||||||
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
|
<div className="space-y-6">
|
||||||
}
|
<SeitenKopf
|
||||||
pfad={[
|
titel={vertrag.bezeichnung}
|
||||||
{ titel: 'Übersicht', href: '/heute' },
|
beschreibung={
|
||||||
{ titel: 'Verträge', href: '/vertraege' },
|
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
|
||||||
{ titel: vertrag.bezeichnung },
|
.filter(Boolean)
|
||||||
]}
|
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
|
||||||
aktionen={
|
}
|
||||||
schreiben && (
|
pfad={[
|
||||||
<div className="flex flex-wrap gap-2">
|
{ titel: 'Übersicht', href: '/heute' },
|
||||||
<Button asChild variant="outline">
|
{ titel: 'Verträge', href: '/vertraege' },
|
||||||
<Link href={`/vertraege/${id}/kuendigen`}>
|
{ titel: vertrag.bezeichnung },
|
||||||
<FileX2 aria-hidden="true" />
|
]}
|
||||||
Kündigen
|
aktionen={
|
||||||
</Link>
|
schreiben && (
|
||||||
</Button>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button asChild>
|
<Button asChild variant="outline">
|
||||||
<Link href={`/vertraege/${id}/bearbeiten`}>
|
<Link href={`/vertraege/${id}/kuendigen`}>
|
||||||
<Pencil aria-hidden="true" />
|
<FileX2 aria-hidden="true" />
|
||||||
Bearbeiten
|
Kündigen
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button asChild>
|
||||||
)
|
<Link href={`/vertraege/${id}/bearbeiten`}>
|
||||||
}
|
<Pencil aria-hidden="true" />
|
||||||
/>
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
<Card>
|
</Button>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
)
|
||||||
<Shield className="size-5" aria-hidden="true" />
|
}
|
||||||
Vertragsdaten
|
/>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
<Card>
|
||||||
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
|
<CardHeader>
|
||||||
</CardDescription>
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardHeader>
|
<Shield className="size-5" aria-hidden="true" />
|
||||||
<CardContent className="grid gap-x-8 sm:grid-cols-2">
|
Vertragsdaten
|
||||||
<dl className="divide-y">
|
</CardTitle>
|
||||||
<Datenzeile beschriftung="Vertragsnummer">
|
<CardDescription>
|
||||||
{offen.vertragsnummer ?? '–'}
|
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
|
||||||
</Datenzeile>
|
</CardDescription>
|
||||||
<Datenzeile beschriftung="Status">
|
</CardHeader>
|
||||||
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
|
<CardContent className="grid gap-x-8 sm:grid-cols-2">
|
||||||
</Datenzeile>
|
<dl className="divide-y">
|
||||||
<Datenzeile beschriftung="Laufzeitbeginn">
|
<Datenzeile beschriftung="Vertragsnummer">
|
||||||
{formatDatum(vertrag.beginnAm)}
|
{offen.vertragsnummer ?? '–'}
|
||||||
</Datenzeile>
|
</Datenzeile>
|
||||||
<Datenzeile beschriftung="Vereinbartes Ende">
|
<Datenzeile beschriftung="Status">
|
||||||
{formatDatum(vertrag.endeAm)}
|
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
|
||||||
</Datenzeile>
|
</Datenzeile>
|
||||||
<Datenzeile beschriftung="Kündigungsfrist">
|
<Datenzeile beschriftung="Laufzeitbeginn">
|
||||||
{vertrag.kuendigungsfristMonate !== null
|
{formatDatum(vertrag.beginnAm)}
|
||||||
? `${vertrag.kuendigungsfristMonate} Monate`
|
</Datenzeile>
|
||||||
: 'nicht erfasst'}
|
<Datenzeile beschriftung="Vereinbartes Ende">
|
||||||
</Datenzeile>
|
{formatDatum(vertrag.endeAm)}
|
||||||
</dl>
|
</Datenzeile>
|
||||||
<dl className="divide-y">
|
<Datenzeile beschriftung="Kündigungsfrist">
|
||||||
<Datenzeile beschriftung="Beitrag">
|
{vertrag.kuendigungsfristMonate !== null
|
||||||
{formatCent(vertrag.beitragCent)}
|
? `${vertrag.kuendigungsfristMonate} Monate`
|
||||||
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
|
: 'nicht erfasst'}
|
||||||
</Datenzeile>
|
</Datenzeile>
|
||||||
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
|
</dl>
|
||||||
<Datenzeile beschriftung="Gekündigt am">
|
<dl className="divide-y">
|
||||||
{formatDatum(vertrag.gekuendigtAm)}
|
<Datenzeile beschriftung="Beitrag">
|
||||||
</Datenzeile>
|
{formatCent(vertrag.beitragCent)}
|
||||||
<Datenzeile beschriftung="Gekündigt zum">
|
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
|
||||||
{formatDatum(vertrag.kuendigungZum)}
|
</Datenzeile>
|
||||||
</Datenzeile>
|
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
|
||||||
<Datenzeile beschriftung="Rückkaufswert angefragt">
|
<Datenzeile beschriftung="Gekündigt am">
|
||||||
{formatDatum(vertrag.rueckkaufswertAngefragtAm)}
|
{formatDatum(vertrag.gekuendigtAm)}
|
||||||
</Datenzeile>
|
</Datenzeile>
|
||||||
</dl>
|
<Datenzeile beschriftung="Gekündigt zum">
|
||||||
|
{formatDatum(vertrag.kuendigungZum)}
|
||||||
{offen.notizen && (
|
</Datenzeile>
|
||||||
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
|
<Datenzeile beschriftung="Rückkaufswert angefragt">
|
||||||
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
{formatDatum(vertrag.rueckkaufswertAngefragtAm)}
|
||||||
</div>
|
</Datenzeile>
|
||||||
)}
|
</dl>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{offen.notizen && (
|
||||||
|
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
|
||||||
<Card>
|
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>Kündigungsfrist</CardTitle>
|
)}
|
||||||
<CardDescription>
|
</CardContent>
|
||||||
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
|
</Card>
|
||||||
Absendetag früher als der Fristablauf.
|
|
||||||
</CardDescription>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardTitle>Kündigungsfrist</CardTitle>
|
||||||
{plan.berechenbar ? (
|
<CardDescription>
|
||||||
<>
|
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
|
||||||
<dl className="grid gap-3 sm:grid-cols-3">
|
Absendetag früher als der Fristablauf.
|
||||||
<div className="rounded-md border p-3">
|
</CardDescription>
|
||||||
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
|
</CardHeader>
|
||||||
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
|
<CardContent className="space-y-4">
|
||||||
</div>
|
{plan.berechenbar ? (
|
||||||
<div className="rounded-md border p-3">
|
<>
|
||||||
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
|
<dl className="grid gap-3 sm:grid-cols-3">
|
||||||
<dd className="text-lg font-semibold">
|
<div className="rounded-md border p-3">
|
||||||
{formatDatum(plan.zugangSpaetestensAm)}
|
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
|
||||||
</dd>
|
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border p-3">
|
<div className="rounded-md border p-3">
|
||||||
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
|
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
|
||||||
<dd className="text-lg font-semibold">
|
<dd className="text-lg font-semibold">
|
||||||
{formatDatum(plan.spaetesterAbsendetag)}
|
{formatDatum(plan.zugangSpaetestensAm)}
|
||||||
</dd>
|
</dd>
|
||||||
{ampel && (
|
</div>
|
||||||
<dd className="mt-1">
|
<div className="rounded-md border p-3">
|
||||||
<Badge variant={ampel.variante}>{ampel.text}</Badge>
|
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
|
||||||
</dd>
|
<dd className="text-lg font-semibold">
|
||||||
)}
|
{formatDatum(plan.spaetesterAbsendetag)}
|
||||||
</div>
|
</dd>
|
||||||
</dl>
|
{ampel && (
|
||||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
<dd className="mt-1">
|
||||||
{plan.hinweise.map((hinweis) => (
|
<Badge variant={ampel.variante}>{ampel.text}</Badge>
|
||||||
<li key={hinweis}>{hinweis}</li>
|
</dd>
|
||||||
))}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
</>
|
</dl>
|
||||||
) : (
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
{plan.hinweise.map((hinweis) => (
|
||||||
{plan.hinweise[0] ??
|
<li key={hinweis}>{hinweis}</li>
|
||||||
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
|
))}
|
||||||
</p>
|
</ul>
|
||||||
)}
|
</>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{plan.hinweise[0] ??
|
||||||
{kannRueckkaufswertHaben(vertrag.art) && (
|
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
|
||||||
<Rueckkaufswert
|
</p>
|
||||||
betreuteId={aktive.id}
|
)}
|
||||||
vertragId={id}
|
</CardContent>
|
||||||
darfSchreiben={schreiben}
|
</Card>
|
||||||
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
|
|
||||||
erinnerungFaellig={anfrage.erinnerungFaellig}
|
{kannRueckkaufswertHaben(vertrag.art) && (
|
||||||
tageSeitAnfrage={anfrage.tageSeitAnfrage}
|
<Rueckkaufswert
|
||||||
anschreiben={anschreiben}
|
betreuteId={aktive.id}
|
||||||
stichtagVorschlag={alsIsoTag(heute)}
|
vertragId={id}
|
||||||
werte={wertstaende.map((w) => ({
|
darfSchreiben={schreiben}
|
||||||
id: w.id,
|
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
|
||||||
stichtag: w.stichtag.toISOString(),
|
erinnerungFaellig={anfrage.erinnerungFaellig}
|
||||||
wertCent: centZuZahl(w.wertCent) ?? 0,
|
tageSeitAnfrage={anfrage.tageSeitAnfrage}
|
||||||
wertquelle: w.wertquelle,
|
anschreiben={anschreiben}
|
||||||
bemerkung: w.bemerkung,
|
stichtagVorschlag={alsIsoTag(heute)}
|
||||||
}))}
|
alterHinweis={
|
||||||
/>
|
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
|
||||||
)}
|
}
|
||||||
|
werte={wertstaende.map((w) => ({
|
||||||
<Card>
|
id: w.id,
|
||||||
<CardHeader>
|
stichtag: w.stichtag.toISOString(),
|
||||||
<CardTitle>Bewertung fürs Sozialamt</CardTitle>
|
wertCent: centZuZahl(w.wertCent) ?? 0,
|
||||||
<CardDescription>
|
wertquelle: w.wertquelle,
|
||||||
Meine Einordnung und meine Begründung — keine Rechtsauskunft.
|
bemerkung: w.bemerkung,
|
||||||
</CardDescription>
|
}))}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent className="space-y-3">
|
)}
|
||||||
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<Card>
|
||||||
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
|
<CardHeader>
|
||||||
</p>
|
<CardTitle>Bewertung fürs Sozialamt</CardTitle>
|
||||||
{offen.bewertungBegruendung ? (
|
<CardDescription>
|
||||||
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
|
Meine Einordnung und meine Begründung — keine Rechtsauskunft.
|
||||||
{offen.bewertungBegruendung}
|
</CardDescription>
|
||||||
</p>
|
</CardHeader>
|
||||||
) : (
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
|
||||||
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
|
<p className="text-sm text-muted-foreground">
|
||||||
Aufstellung an den Bezirk auftaucht.
|
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
|
||||||
</p>
|
</p>
|
||||||
)}
|
{offen.bewertungBegruendung ? (
|
||||||
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
|
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
|
||||||
</CardContent>
|
{offen.bewertungBegruendung}
|
||||||
</Card>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+260
-235
@@ -1,235 +1,260 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Plus, Shield } from 'lucide-react'
|
import { FileArchive, Plus, Shield } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
|
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import {
|
import {
|
||||||
KuendigungsUebersicht,
|
KuendigungsUebersicht,
|
||||||
type Kuendigungswarnung,
|
type Kuendigungswarnung,
|
||||||
} from '@/components/vertraege/kuendigungs-uebersicht'
|
} from '@/components/vertraege/kuendigungs-uebersicht'
|
||||||
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import { heuteAlsTag } from '@/lib/fristen/datum'
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
import {
|
import {
|
||||||
kuendigungsampel,
|
kuendigungsampel,
|
||||||
kuendigungsplan,
|
kuendigungsplan,
|
||||||
laufzeitAusVertrag,
|
laufzeitAusVertrag,
|
||||||
} from '@/lib/vertraege/kuendigung'
|
} from '@/lib/vertraege/kuendigung'
|
||||||
import {
|
import {
|
||||||
BEWERTUNG_BESCHRIFTUNG,
|
BEWERTUNG_BESCHRIFTUNG,
|
||||||
jahresbeitragCent,
|
jahresbeitragCent,
|
||||||
RHYTHMUS_BESCHRIFTUNG,
|
kannRueckkaufswertHaben,
|
||||||
VERTRAG_ART_BESCHRIFTUNG,
|
RHYTHMUS_BESCHRIFTUNG,
|
||||||
VERTRAG_STATUS_BESCHRIFTUNG,
|
VERTRAG_ART_BESCHRIFTUNG,
|
||||||
} from '@/lib/vertraege/konstanten'
|
VERTRAG_STATUS_BESCHRIFTUNG,
|
||||||
import { formatCent, formatDatum } from '@/lib/utils'
|
} from '@/lib/vertraege/konstanten'
|
||||||
|
import { juengsterWert } from '@/lib/vertraege/rueckkaufswert'
|
||||||
export const dynamic = 'force-dynamic'
|
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
|
||||||
|
import { formatCent, formatDatum } from '@/lib/utils'
|
||||||
const BEWERTUNG_VARIANTE = {
|
|
||||||
UNGEPRUEFT: 'outline',
|
export const dynamic = 'force-dynamic'
|
||||||
VERWERTBAR: 'orange',
|
|
||||||
GESCHUETZT: 'gruen',
|
const BEWERTUNG_VARIANTE = {
|
||||||
STRITTIG: 'gelb',
|
UNGEPRUEFT: 'outline',
|
||||||
} as const
|
VERWERTBAR: 'orange',
|
||||||
|
GESCHUETZT: 'gruen',
|
||||||
export default async function VertraegeUebersicht() {
|
STRITTIG: 'gelb',
|
||||||
const benutzer = await erfordereBenutzer('/vertraege')
|
} as const
|
||||||
const aktive = await aktiveBetreute(benutzer)
|
|
||||||
if (!aktive) return null
|
export default async function VertraegeUebersicht() {
|
||||||
|
const benutzer = await erfordereBenutzer('/vertraege')
|
||||||
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
const schreiben = darfSchreiben('VERTRAEGE')
|
if (!aktive) return null
|
||||||
|
|
||||||
const vertraege = await db.vertrag.findMany({
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
||||||
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
const schreiben = darfSchreiben('VERTRAEGE')
|
||||||
include: {
|
|
||||||
vermoegensposten: {
|
const vertraege = await db.vertrag.findMany({
|
||||||
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
|
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
|
include: {
|
||||||
},
|
// Ueber die Verknuepfung, nicht ueber die Art: Ein von Hand als
|
||||||
},
|
// "Sonstiges" angelegter Posten meint denselben Vertrag.
|
||||||
orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }],
|
vermoegensposten: {
|
||||||
})
|
where: nichtGeloescht,
|
||||||
|
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
|
||||||
const heute = heuteAlsTag()
|
},
|
||||||
const warnungen: Kuendigungswarnung[] = []
|
},
|
||||||
let jahresbeitragSumme = 0n
|
orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }],
|
||||||
|
})
|
||||||
const zeilen = vertraege.map((vertrag) => {
|
|
||||||
const offen = oeffnen('Vertrag', vertrag)
|
const heute = heuteAlsTag()
|
||||||
const plan = kuendigungsplan({
|
const warnungen: Kuendigungswarnung[] = []
|
||||||
beginnAm: vertrag.beginnAm,
|
let jahresbeitragSumme = 0n
|
||||||
endeAm: vertrag.endeAm,
|
|
||||||
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
const zeilen = vertraege.map((vertrag) => {
|
||||||
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
heute,
|
const plan = kuendigungsplan({
|
||||||
})
|
beginnAm: vertrag.beginnAm,
|
||||||
const ampel = kuendigungsampel(plan)
|
endeAm: vertrag.endeAm,
|
||||||
|
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
||||||
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
|
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
||||||
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
|
heute,
|
||||||
|
})
|
||||||
const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI'
|
const ampel = kuendigungsampel(plan)
|
||||||
if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) {
|
|
||||||
warnungen.push({
|
const letzterWert = juengsterWert(vertrag.vermoegensposten)
|
||||||
vertragId: vertrag.id,
|
|
||||||
bezeichnung: vertrag.bezeichnung,
|
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
|
||||||
absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null,
|
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
|
||||||
tageBisAbsendetag: plan.tageBisAbsendetag,
|
|
||||||
verpasst: plan.verpasst,
|
const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI'
|
||||||
variante: ampel.variante,
|
if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) {
|
||||||
text: ampel.text,
|
warnungen.push({
|
||||||
})
|
vertragId: vertrag.id,
|
||||||
}
|
bezeichnung: vertrag.bezeichnung,
|
||||||
|
absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null,
|
||||||
return {
|
tageBisAbsendetag: plan.tageBisAbsendetag,
|
||||||
id: vertrag.id,
|
verpasst: plan.verpasst,
|
||||||
bezeichnung: vertrag.bezeichnung,
|
variante: ampel.variante,
|
||||||
gesellschaft: vertrag.gesellschaft,
|
text: ampel.text,
|
||||||
art: vertrag.art,
|
})
|
||||||
status: vertrag.status,
|
}
|
||||||
vertragsnummer: offen.vertragsnummer,
|
|
||||||
beitragCent: centZuZahl(vertrag.beitragCent),
|
return {
|
||||||
rhythmus: vertrag.rhythmus,
|
id: vertrag.id,
|
||||||
bewertung: vertrag.bewertungSozialhilfe,
|
bezeichnung: vertrag.bezeichnung,
|
||||||
rueckkaufswertCent: centZuZahl(
|
gesellschaft: vertrag.gesellschaft,
|
||||||
vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null,
|
art: vertrag.art,
|
||||||
),
|
status: vertrag.status,
|
||||||
rueckkaufswertStichtag:
|
vertragsnummer: offen.vertragsnummer,
|
||||||
vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null,
|
beitragCent: centZuZahl(vertrag.beitragCent),
|
||||||
ampel,
|
rhythmus: vertrag.rhythmus,
|
||||||
}
|
bewertung: vertrag.bewertungSozialhilfe,
|
||||||
})
|
rueckkaufswertCent: centZuZahl(letzterWert?.wertCent ?? null),
|
||||||
|
rueckkaufswertStichtag: letzterWert?.stichtag.toISOString() ?? null,
|
||||||
return (
|
rueckkaufswertAlter: wertalterBestimmen(letzterWert?.stichtag ?? null, heute),
|
||||||
<div className="space-y-6">
|
wertErwartet: kannRueckkaufswertHaben(vertrag.art),
|
||||||
<SeitenKopf
|
ampel,
|
||||||
titel="Versicherungen & Verträge"
|
}
|
||||||
beschreibung="Alle laufenden Verträge mit Beitrag, Kündigungsfrist, Rückkaufswert und der Einordnung fürs Sozialamt."
|
})
|
||||||
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
|
|
||||||
aktionen={
|
return (
|
||||||
schreiben && (
|
<div className="space-y-6">
|
||||||
<Button asChild>
|
<SeitenKopf
|
||||||
<Link href="/vertraege/neu">
|
titel="Versicherungen & Verträge"
|
||||||
<Plus aria-hidden="true" />
|
beschreibung="Alle laufenden Verträge mit Beitrag, Kündigungsfrist, Rückkaufswert und der Einordnung fürs Sozialamt."
|
||||||
Vertrag anlegen
|
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
|
||||||
</Link>
|
aktionen={
|
||||||
</Button>
|
schreiben && (
|
||||||
)
|
<>
|
||||||
}
|
<Button asChild variant="outline">
|
||||||
/>
|
<Link href="/vertraege/serienabfrage/seite">
|
||||||
|
<FileArchive aria-hidden="true" />
|
||||||
<KuendigungsUebersicht warnungen={warnungen} />
|
Rückkaufswerte anfragen
|
||||||
|
</Link>
|
||||||
{vertraege.length === 0 ? (
|
</Button>
|
||||||
<Leerzustand
|
<Button asChild>
|
||||||
titel="Noch kein Vertrag erfasst"
|
<Link href="/vertraege/neu">
|
||||||
beschreibung="Nimm die Kontoauszüge des letzten Jahres zur Hand: Jede wiederkehrende Abbuchung ist ein Vertrag. Das ist der zuverlässigste Weg, nichts zu übersehen — Policen liegen selten vollständig im Ordner."
|
<Plus aria-hidden="true" />
|
||||||
icon={Shield}
|
Vertrag anlegen
|
||||||
aktion={
|
</Link>
|
||||||
schreiben && (
|
</Button>
|
||||||
<Button asChild>
|
</>
|
||||||
<Link href="/vertraege/neu">
|
)
|
||||||
<Plus aria-hidden="true" />
|
}
|
||||||
Vertrag anlegen
|
/>
|
||||||
</Link>
|
|
||||||
</Button>
|
<KuendigungsUebersicht warnungen={warnungen} />
|
||||||
)
|
|
||||||
}
|
{vertraege.length === 0 ? (
|
||||||
/>
|
<Leerzustand
|
||||||
) : (
|
titel="Noch kein Vertrag erfasst"
|
||||||
<Card>
|
beschreibung="Nimm die Kontoauszüge des letzten Jahres zur Hand: Jede wiederkehrende Abbuchung ist ein Vertrag. Das ist der zuverlässigste Weg, nichts zu übersehen — Policen liegen selten vollständig im Ordner."
|
||||||
<CardHeader>
|
icon={Shield}
|
||||||
<CardTitle>Alle Verträge</CardTitle>
|
aktion={
|
||||||
<CardDescription>
|
schreiben && (
|
||||||
Jahresbeitrag der aktiven Verträge zusammen:{' '}
|
<Button asChild>
|
||||||
<strong className="text-foreground">
|
<Link href="/vertraege/neu">
|
||||||
{formatCent(centZuZahl(jahresbeitragSumme))}
|
<Plus aria-hidden="true" />
|
||||||
</strong>
|
Vertrag anlegen
|
||||||
</CardDescription>
|
</Link>
|
||||||
</CardHeader>
|
</Button>
|
||||||
<CardContent>
|
)
|
||||||
<Table>
|
}
|
||||||
<TableHeader>
|
/>
|
||||||
<TableRow>
|
) : (
|
||||||
<TableHead>Vertrag</TableHead>
|
<Card>
|
||||||
<TableHead>Art</TableHead>
|
<CardHeader>
|
||||||
<TableHead className="text-right">Beitrag</TableHead>
|
<CardTitle>Alle Verträge</CardTitle>
|
||||||
<TableHead className="text-right">Rückkaufswert</TableHead>
|
<CardDescription>
|
||||||
<TableHead>Kündigung</TableHead>
|
Jahresbeitrag der aktiven Verträge zusammen:{' '}
|
||||||
<TableHead>Sozialamt</TableHead>
|
<strong className="text-foreground">
|
||||||
<TableHead>Status</TableHead>
|
{formatCent(centZuZahl(jahresbeitragSumme))}
|
||||||
</TableRow>
|
</strong>
|
||||||
</TableHeader>
|
</CardDescription>
|
||||||
<TableBody>
|
</CardHeader>
|
||||||
{zeilen.map((zeile) => (
|
<CardContent>
|
||||||
<TableRow key={zeile.id}>
|
<Table>
|
||||||
<TableCell>
|
<TableHeader>
|
||||||
<Link
|
<TableRow>
|
||||||
href={`/vertraege/${zeile.id}`}
|
<TableHead>Vertrag</TableHead>
|
||||||
className="font-medium hover:underline"
|
<TableHead>Art</TableHead>
|
||||||
>
|
<TableHead className="text-right">Beitrag</TableHead>
|
||||||
{zeile.bezeichnung}
|
<TableHead className="text-right">Rückkaufswert</TableHead>
|
||||||
</Link>
|
<TableHead>Kündigung</TableHead>
|
||||||
<span className="block text-sm text-muted-foreground">
|
<TableHead>Sozialamt</TableHead>
|
||||||
{[zeile.gesellschaft, zeile.vertragsnummer].filter(Boolean).join(' · ') ||
|
<TableHead>Status</TableHead>
|
||||||
'Gesellschaft nicht erfasst'}
|
</TableRow>
|
||||||
</span>
|
</TableHeader>
|
||||||
</TableCell>
|
<TableBody>
|
||||||
<TableCell className="text-sm">
|
{zeilen.map((zeile) => (
|
||||||
{VERTRAG_ART_BESCHRIFTUNG[zeile.art]}
|
<TableRow key={zeile.id}>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-right tabular-nums">
|
<Link
|
||||||
{formatCent(zeile.beitragCent)}
|
href={`/vertraege/${zeile.id}`}
|
||||||
{zeile.rhythmus && (
|
className="font-medium hover:underline"
|
||||||
<span className="block text-xs text-muted-foreground">
|
>
|
||||||
{RHYTHMUS_BESCHRIFTUNG[zeile.rhythmus]}
|
{zeile.bezeichnung}
|
||||||
</span>
|
</Link>
|
||||||
)}
|
<span className="block text-sm text-muted-foreground">
|
||||||
</TableCell>
|
{[zeile.gesellschaft, zeile.vertragsnummer].filter(Boolean).join(' · ') ||
|
||||||
<TableCell className="text-right tabular-nums">
|
'Gesellschaft nicht erfasst'}
|
||||||
{formatCent(zeile.rueckkaufswertCent)}
|
</span>
|
||||||
{zeile.rueckkaufswertStichtag && (
|
</TableCell>
|
||||||
<span className="block text-xs text-muted-foreground">
|
<TableCell className="text-sm">
|
||||||
{formatDatum(zeile.rueckkaufswertStichtag)}
|
{VERTRAG_ART_BESCHRIFTUNG[zeile.art]}
|
||||||
</span>
|
</TableCell>
|
||||||
)}
|
<TableCell className="text-right tabular-nums">
|
||||||
</TableCell>
|
{formatCent(zeile.beitragCent)}
|
||||||
<TableCell>
|
{zeile.rhythmus && (
|
||||||
{zeile.ampel ? (
|
<span className="block text-xs text-muted-foreground">
|
||||||
<Badge variant={zeile.ampel.variante}>{zeile.ampel.text}</Badge>
|
{RHYTHMUS_BESCHRIFTUNG[zeile.rhythmus]}
|
||||||
) : (
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">nicht berechenbar</span>
|
)}
|
||||||
)}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-right tabular-nums">
|
||||||
<TableCell>
|
{zeile.rueckkaufswertStichtag ? (
|
||||||
<Badge variant={BEWERTUNG_VARIANTE[zeile.bewertung]}>
|
<>
|
||||||
{BEWERTUNG_BESCHRIFTUNG[zeile.bewertung]}
|
{formatCent(zeile.rueckkaufswertCent)}
|
||||||
</Badge>
|
<span className="block text-xs text-muted-foreground">
|
||||||
</TableCell>
|
{formatDatum(zeile.rueckkaufswertStichtag)}
|
||||||
<TableCell className="text-sm">
|
</span>
|
||||||
{VERTRAG_STATUS_BESCHRIFTUNG[zeile.status]}
|
{zeile.rueckkaufswertAlter?.veraltet && (
|
||||||
</TableCell>
|
<Badge variant="gelb" className="mt-1">
|
||||||
</TableRow>
|
{zeile.rueckkaufswertAlter.hinweis}
|
||||||
))}
|
</Badge>
|
||||||
</TableBody>
|
)}
|
||||||
</Table>
|
</>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
)}
|
{zeile.wertErwartet ? 'noch nicht erfasst' : '–'}
|
||||||
|
</span>
|
||||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 1850 BGB" />
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
</TableCell>
|
||||||
Kurz übersetzt: Ob ein Vertrag vor der Sozialhilfe einzusetzen ist, richtet sich nach
|
<TableCell>
|
||||||
§ 90 SGB XII — eine angemessene Bestattungsvorsorge bleibt regelmäßig geschützt. Und
|
{zeile.ampel ? (
|
||||||
bevor eine Lebens- oder Rentenversicherung verwertet wird, gehört die Frage nach der
|
<Badge variant={zeile.ampel.variante}>{zeile.ampel.text}</Badge>
|
||||||
Genehmigung des Betreuungsgerichts (§ 1850 BGB) an den Rechtspfleger, nicht an diese App.
|
) : (
|
||||||
</p>
|
<span className="text-sm text-muted-foreground">nicht berechenbar</span>
|
||||||
</div>
|
)}
|
||||||
)
|
</TableCell>
|
||||||
}
|
<TableCell>
|
||||||
|
<Badge variant={BEWERTUNG_VARIANTE[zeile.bewertung]}>
|
||||||
|
{BEWERTUNG_BESCHRIFTUNG[zeile.bewertung]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{VERTRAG_STATUS_BESCHRIFTUNG[zeile.status]}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 1850 BGB" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Kurz übersetzt: Ob ein Vertrag vor der Sozialhilfe einzusetzen ist, richtet sich nach
|
||||||
|
§ 90 SGB XII — eine angemessene Bestattungsvorsorge bleibt regelmäßig geschützt. Und
|
||||||
|
bevor eine Lebens- oder Rentenversicherung verwertet wird, gehört die Frage nach der
|
||||||
|
Genehmigung des Betreuungsgerichts (§ 1850 BGB) an den Rechtspfleger, nicht an diese App.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { serienlaufDurchfuehren } from '@/lib/vertraege/serienlauf'
|
||||||
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt die Serienabfrage durch und liefert das ZIP.
|
||||||
|
*
|
||||||
|
* POST wie bei den anderen Downloads: Die Auswahl kommt mit, und der Lauf
|
||||||
|
* hat Nebenwirkungen (Dokumente, Fristen, Anfragedatum) - ein GET, das
|
||||||
|
* schreibt, waere eine Falle fuer jeden Vorabruf durch den Browser.
|
||||||
|
*
|
||||||
|
* Die Nebenwirkungen passieren VOR der Auslieferung. Faellt danach noch
|
||||||
|
* etwas beim Herunterladen aus, sind Akte und Fristen trotzdem korrekt -
|
||||||
|
* und die Schreiben liegen als PDF ohnehin in der Akte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request): Promise<Response> {
|
||||||
|
let formular: FormData
|
||||||
|
try {
|
||||||
|
formular = await anfrage.formData()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||||
|
const stichtagRoh = String(formular.get('stichtag') ?? '').trim()
|
||||||
|
const vertragIds = formular
|
||||||
|
.getAll('vertragId')
|
||||||
|
.map((wert) => String(wert).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!betreuteId || vertragIds.length === 0) {
|
||||||
|
return NextResponse.json({ fehler: 'Es ist kein Vertrag ausgewählt.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
// Obergrenze als Schutz vor Unsinn - real sind es fuenf bis zehn.
|
||||||
|
if (vertragIds.length > 100) {
|
||||||
|
return NextResponse.json({ fehler: 'Zu viele Verträge auf einmal.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stichtag = tagAusText(stichtagRoh)
|
||||||
|
if (!stichtag) {
|
||||||
|
return NextResponse.json({ fehler: 'Bitte einen gültigen Stichtag angeben.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let benutzer
|
||||||
|
try {
|
||||||
|
const zugang = await erfordereZugang(betreuteId, 'VERTRAEGE', 'schreiben')
|
||||||
|
benutzer = zugang.benutzer
|
||||||
|
} catch (ausnahme) {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) {
|
||||||
|
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
|
||||||
|
}
|
||||||
|
throw ausnahme
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = await serienlaufDurchfuehren({
|
||||||
|
betreuteId,
|
||||||
|
vertragIds,
|
||||||
|
stichtag,
|
||||||
|
benutzer: { id: benutzer.id, name: benutzer.name, email: benutzer.email },
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(ergebnis.zip), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${ergebnis.dateiname}"`,
|
||||||
|
'Content-Length': String(ergebnis.zip.length),
|
||||||
|
'Cache-Control': 'no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { Scale } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import {
|
||||||
|
SerienabfrageFormular,
|
||||||
|
type SerienZeile,
|
||||||
|
} from '@/components/vertraege/serienabfrage-formular'
|
||||||
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { kandidatenBewerten } from '@/lib/vertraege/serienabfrage'
|
||||||
|
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Rückkaufswerte anfragen' }
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Serienabfrage: alle Rückkaufswerte in einem Zug.
|
||||||
|
*
|
||||||
|
* Liegt unter /vertraege/serienabfrage/seite, weil /vertraege/serienabfrage
|
||||||
|
* die POST-Route ist, die das ZIP liefert. Die Trennung ist Absicht: Die
|
||||||
|
* Route hat Nebenwirkungen und gehört nicht hinter einen GET.
|
||||||
|
*/
|
||||||
|
export default async function SerienabfrageSeite() {
|
||||||
|
const benutzer = await erfordereBenutzer('/vertraege/serienabfrage/seite')
|
||||||
|
const aktive = await aktiveBetreute(benutzer)
|
||||||
|
if (!aktive) redirect('/ersteinrichtung')
|
||||||
|
|
||||||
|
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
|
||||||
|
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
|
||||||
|
|
||||||
|
const vertraege = await db.vertrag.findMany({
|
||||||
|
where: { betreuteId: aktive.id, ...nichtGeloescht },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
gesellschaft: true,
|
||||||
|
art: true,
|
||||||
|
status: true,
|
||||||
|
rueckkaufswertAngefragtAm: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const bewertet = kandidatenBewerten(vertraege)
|
||||||
|
|
||||||
|
// Vertraege ohne jede Aussicht auf einen Rueckkaufswert (Strom, Telefon,
|
||||||
|
// Rundfunk) wuerden die Liste nur verlaengern - sie bleiben draussen.
|
||||||
|
// Alles andere steht drin, notfalls mit Hindernis.
|
||||||
|
const zeilen: SerienZeile[] = bewertet
|
||||||
|
.filter(
|
||||||
|
(k) => k.moeglich || k.hindernis !== 'Diese Vertragsart bildet keinen Rückkaufswert.',
|
||||||
|
)
|
||||||
|
.map((k) => ({
|
||||||
|
vertragId: k.vertrag.id,
|
||||||
|
bezeichnung: k.vertrag.bezeichnung,
|
||||||
|
gesellschaft: k.vertrag.gesellschaft,
|
||||||
|
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[k.vertrag.art],
|
||||||
|
moeglich: k.moeglich,
|
||||||
|
hindernis: k.hindernis,
|
||||||
|
vorausgewaehlt: k.vorausgewaehlt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Rückkaufswerte anfragen"
|
||||||
|
beschreibung="Alle Lebens- und Rentenversicherungen in einem Zug — ein ZIP mit einem Schreiben je Gesellschaft, die Nachweise in der Akte, die Wiedervorlagen gesetzt."
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Verträge', href: '/vertraege' },
|
||||||
|
{ titel: 'Rückkaufswerte anfragen' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SerienabfrageFormular
|
||||||
|
betreuteId={aktive.id}
|
||||||
|
zeilen={zeilen}
|
||||||
|
stichtagVorschlag={heuteAlsTag().toISOString().slice(0, 10)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<Scale aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Die Schreiben sind Entwürfe zum Ausdrucken und Unterschreiben — versendet wird nichts
|
||||||
|
automatisch. Keine Rechtsberatung; bei Fragen zur Verwertungspflicht entscheidet der
|
||||||
|
Sozialhilfeträger.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Clock } from 'lucide-react'
|
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
|
||||||
import type { VorgangStatus } from '@prisma/client'
|
import type { VorgangStatus } from '@prisma/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -56,6 +56,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
|
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
|
||||||
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
|
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
|
||||||
verknuepfungen: { include: { dokument: true } },
|
verknuepfungen: { include: { dokument: true } },
|
||||||
|
ansprechpartner: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!vorgang) notFound()
|
if (!vorgang) notFound()
|
||||||
@@ -65,6 +66,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
|
|
||||||
const offen = oeffnen('Vorgang', vorgang)
|
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 --------------------------------------
|
// --- Statuswechsel aus dem Audit-Log --------------------------------------
|
||||||
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
|
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
|
||||||
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
|
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
|
||||||
@@ -182,6 +191,12 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
aktenzeichenGericht: aktive.aktenzeichenGericht,
|
aktenzeichenGericht: aktive.aktenzeichenGericht,
|
||||||
betreuungsgericht: aktive.betreuungsgericht,
|
betreuungsgericht: aktive.betreuungsgericht,
|
||||||
},
|
},
|
||||||
|
ansprechpartner: vorgang.ansprechpartner
|
||||||
|
? {
|
||||||
|
name: vorgang.ansprechpartner.name,
|
||||||
|
anrede: vorgang.ansprechpartner.anrede,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -245,6 +260,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
darfSchreiben={schreiben}
|
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} />}
|
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -277,6 +300,40 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
|
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
|
||||||
|
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">
|
<div className="space-y-1 sm:col-span-2">
|
||||||
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
|
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
@@ -355,6 +412,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
betreuteId={aktive.id}
|
betreuteId={aktive.id}
|
||||||
vorgangId={vorgang.id}
|
vorgangId={vorgang.id}
|
||||||
abbrechenHref={`/vorgaenge/${vorgang.id}`}
|
abbrechenHref={`/vorgaenge/${vorgang.id}`}
|
||||||
|
kontakte={kontakte}
|
||||||
werte={{
|
werte={{
|
||||||
titel: vorgang.titel,
|
titel: vorgang.titel,
|
||||||
kategorie: vorgang.kategorie,
|
kategorie: vorgang.kategorie,
|
||||||
@@ -363,6 +421,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
|
|||||||
beschreibung: offen.beschreibung ?? null,
|
beschreibung: offen.beschreibung ?? null,
|
||||||
gegenstelle: vorgang.gegenstelle,
|
gegenstelle: vorgang.gegenstelle,
|
||||||
aktenzeichen: vorgang.aktenzeichen,
|
aktenzeichen: vorgang.aktenzeichen,
|
||||||
|
ansprechpartnerId: vorgang.ansprechpartnerId,
|
||||||
verantwortlich: vorgang.verantwortlich,
|
verantwortlich: vorgang.verantwortlich,
|
||||||
naechsterSchritt: vorgang.naechsterSchritt,
|
naechsterSchritt: vorgang.naechsterSchritt,
|
||||||
faelligAm: vorgang.faelligAm,
|
faelligAm: vorgang.faelligAm,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
|
|||||||
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
|
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
|
||||||
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
|
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { formatDatum } from '@/lib/utils'
|
import { formatDatum } from '@/lib/utils'
|
||||||
import { addDays } from 'date-fns'
|
import { addDays } from 'date-fns'
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({
|
|||||||
|
|
||||||
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
|
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
const auswahl = await searchParams
|
const auswahl = await searchParams
|
||||||
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
|
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
|
||||||
const leer = auswahl.leer === '1'
|
const leer = auswahl.leer === '1'
|
||||||
@@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({
|
|||||||
betreuteId={aktive.id}
|
betreuteId={aktive.id}
|
||||||
vorlageSchluessel={vorlage?.schluessel ?? null}
|
vorlageSchluessel={vorlage?.schluessel ?? null}
|
||||||
abbrechenHref="/vorgaenge"
|
abbrechenHref="/vorgaenge"
|
||||||
|
kontakte={kontakte}
|
||||||
werte={{
|
werte={{
|
||||||
titel: vorlage?.titel ?? '',
|
titel: vorlage?.titel ?? '',
|
||||||
kategorie: vorlage?.kategorie ?? 'SONSTIGES',
|
kategorie: vorlage?.kategorie ?? 'SONSTIGES',
|
||||||
@@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({
|
|||||||
beschreibung: vorlage?.beschreibung ?? null,
|
beschreibung: vorlage?.beschreibung ?? null,
|
||||||
gegenstelle: null,
|
gegenstelle: null,
|
||||||
aktenzeichen: null,
|
aktenzeichen: null,
|
||||||
|
ansprechpartnerId: null,
|
||||||
verantwortlich: benutzer.name,
|
verantwortlich: benutzer.name,
|
||||||
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
|
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
|
||||||
faelligAm: null,
|
faelligAm: null,
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ import {
|
|||||||
*
|
*
|
||||||
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
|
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
|
||||||
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
|
* 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[] }) {
|
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||||
|
import {
|
||||||
|
vorlageLoeschen,
|
||||||
|
vorlageSpeichern,
|
||||||
|
type VorlagenZustand,
|
||||||
|
} from '@/server/actions/vorlagen'
|
||||||
|
import { PLATZHALTER, benutztePlatzhalter } from '@/lib/schreiben/platzhalter'
|
||||||
|
|
||||||
|
export type VorlagenZeile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MitgelieferteVorlage = {
|
||||||
|
schluessel: string
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltung der eigenen Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Zwei Listen, ein Editor. Die mitgelieferten Vorlagen sind Code und
|
||||||
|
* unveränderlich — von ihnen führt nur ein Weg hierher: „Als Kopie
|
||||||
|
* übernehmen". Damit kann nichts kaputtgehen, was die Anwendung braucht,
|
||||||
|
* und „wiederherstellen" ist schlicht: noch einmal kopieren.
|
||||||
|
*/
|
||||||
|
export function VorlagenVerwaltung({
|
||||||
|
betreuteId,
|
||||||
|
eigene,
|
||||||
|
mitgelieferte,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
eigene: VorlagenZeile[]
|
||||||
|
mitgelieferte: MitgelieferteVorlage[]
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = React.useState(false)
|
||||||
|
const [bearbeitet, setBearbeitet] = React.useState<VorlagenZeile | null>(null)
|
||||||
|
const [name, setName] = React.useState('')
|
||||||
|
const [betreff, setBetreff] = React.useState('')
|
||||||
|
const [text, setText] = React.useState('')
|
||||||
|
|
||||||
|
function neuBeginnen(vorlage?: Partial<VorlagenZeile>) {
|
||||||
|
setBearbeitet(null)
|
||||||
|
setName(vorlage?.name ?? '')
|
||||||
|
setBetreff(vorlage?.betreff ?? '')
|
||||||
|
setText(vorlage?.text ?? '')
|
||||||
|
setOffen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearbeiten(vorlage: VorlagenZeile) {
|
||||||
|
setBearbeitet(vorlage)
|
||||||
|
setName(vorlage.name)
|
||||||
|
setBetreff(vorlage.betreff)
|
||||||
|
setText(vorlage.text)
|
||||||
|
setOffen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* --- Eigene Vorlagen ---------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Eigene Vorlagen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Formulierungen, die sich bewährt haben — einmal hinterlegt, beim Schreiben wählbar.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{eigene.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Noch keine eigene Vorlage. Fang mit einer Kopie einer mitgelieferten an — oder mit
|
||||||
|
einem leeren Blatt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{eigene.map((vorlage) => (
|
||||||
|
<li key={vorlage.id} className="flex flex-wrap items-center gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{vorlage.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{benutztePlatzhalter(vorlage.text).length} Platzhalter
|
||||||
|
{vorlage.betreff ? ` · Betreff: ${vorlage.betreff}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => bearbeiten(vorlage)}>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<VorlageLoeschKnopf betreuteId={betreuteId} vorlage={vorlage} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" onClick={() => neuBeginnen()}>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Neue Vorlage
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Mitgelieferte ------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Mitgelieferte Vorlagen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Unveränderlich — sie sind Teil der Anwendung und lassen sich nicht kaputtmachen. Als
|
||||||
|
Grundlage für eigene Fassungen: kopieren und anpassen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{mitgelieferte.map((vorlage) => (
|
||||||
|
<li key={vorlage.schluessel} className="flex flex-wrap items-center gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{vorlage.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{vorlage.beschreibung}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">mitgeliefert</Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
neuBeginnen({
|
||||||
|
name: `${vorlage.name} (eigene Fassung)`,
|
||||||
|
betreff: vorlage.betreff,
|
||||||
|
text: vorlage.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" />
|
||||||
|
Als Kopie übernehmen
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Editor --------------------------------------------------------- */}
|
||||||
|
{offen && (
|
||||||
|
<VorlagenEditor
|
||||||
|
betreuteId={betreuteId}
|
||||||
|
vorlageId={bearbeitet?.id ?? ''}
|
||||||
|
name={name}
|
||||||
|
betreff={betreff}
|
||||||
|
text={text}
|
||||||
|
onName={setName}
|
||||||
|
onBetreff={setBetreff}
|
||||||
|
onText={setText}
|
||||||
|
onSchliessen={() => setOffen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VorlageLoeschKnopf({
|
||||||
|
betreuteId,
|
||||||
|
vorlage,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
vorlage: VorlagenZeile
|
||||||
|
}) {
|
||||||
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageLoeschen, {})
|
||||||
|
const [gefragt, setGefragt] = React.useState(false)
|
||||||
|
|
||||||
|
if (!gefragt) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{zustand.fehler && <span className="text-xs text-destructive">{zustand.fehler}</span>}
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(true)}>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={absenden} className="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
<input type="hidden" name="vorlageId" value={vorlage.id} />
|
||||||
|
<Absendeknopf variant="destructive" size="sm" wartetext="…">
|
||||||
|
In den Papierkorb
|
||||||
|
</Absendeknopf>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Editor mit der Platzhalterliste daneben.
|
||||||
|
*
|
||||||
|
* Die Warnung zum Bestellungsabsatz kommt vom Server (zweistufig): Der
|
||||||
|
* erste Speicherversuch ohne ihn liefert die Warnung zurück, erst die
|
||||||
|
* Bestätigung speichert wirklich. So bleibt die Prüfung an EINER Stelle —
|
||||||
|
* eine zweite Prüfung im Browser könnte von der Serverfassung abweichen.
|
||||||
|
*/
|
||||||
|
function VorlagenEditor({
|
||||||
|
betreuteId,
|
||||||
|
vorlageId,
|
||||||
|
name,
|
||||||
|
betreff,
|
||||||
|
text,
|
||||||
|
onName,
|
||||||
|
onBetreff,
|
||||||
|
onText,
|
||||||
|
onSchliessen,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
vorlageId: string
|
||||||
|
name: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
onName: (wert: string) => void
|
||||||
|
onBetreff: (wert: string) => void
|
||||||
|
onText: (wert: string) => void
|
||||||
|
onSchliessen: () => void
|
||||||
|
}) {
|
||||||
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageSpeichern, {})
|
||||||
|
const [trotzdem, setTrotzdem] = React.useState(false)
|
||||||
|
|
||||||
|
const textfeld = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
function platzhalterEinfuegen(platzhalterName: string) {
|
||||||
|
const feld = textfeld.current
|
||||||
|
const einschub = `{{${platzhalterName}}}`
|
||||||
|
if (!feld) {
|
||||||
|
onText(`${text}${einschub}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = feld.selectionStart ?? text.length
|
||||||
|
const ende = feld.selectionEnd ?? text.length
|
||||||
|
onText(`${text.slice(0, start)}${einschub}${text.slice(ende)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{vorlageId ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={absenden} className="grid gap-6 lg:grid-cols-[1fr_260px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
<input type="hidden" name="vorlageId" value={vorlageId} />
|
||||||
|
<input type="hidden" name="ohneBestellungsabsatz" value={trotzdem ? 'ja' : ''} />
|
||||||
|
|
||||||
|
{zustand.fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zustand.warnung && (
|
||||||
|
<Alert variant="warnung">
|
||||||
|
<AlertTriangle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{zustand.warnung}</p>
|
||||||
|
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trotzdem}
|
||||||
|
onChange={(ereignis) => setTrotzdem(ereignis.target.checked)}
|
||||||
|
/>
|
||||||
|
Bewusst ohne Bestellungsabsatz speichern
|
||||||
|
</label>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zustand.erfolg && (
|
||||||
|
<Alert variant="erfolg">
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Feld id="name" beschriftung="Name der Vorlage" fehler={zustand.felder?.name} pflicht>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(ereignis) => onName(ereignis.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Auskunftsersuchen Krankenkasse"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="betreff"
|
||||||
|
beschriftung="Betreff"
|
||||||
|
erklaerung="Darf Platzhalter enthalten. Wird beim Erzeugen zum Betreff des Schreibens."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="betreff"
|
||||||
|
value={betreff}
|
||||||
|
onChange={(ereignis) => onBetreff(ereignis.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld id="text" beschriftung="Text der Vorlage" fehler={zustand.felder?.text} pflicht>
|
||||||
|
<Textarea
|
||||||
|
ref={textfeld}
|
||||||
|
name="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(ereignis) => onText(ereignis.target.value)}
|
||||||
|
rows={18}
|
||||||
|
className="font-mono text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Absendeknopf wartetext="Wird gespeichert …">Speichern</Absendeknopf>
|
||||||
|
<Button type="button" variant="ghost" onClick={onSchliessen}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die Platzhalterliste - niemand soll Namen raten muessen. */}
|
||||||
|
<aside className="space-y-2 rounded-md border p-3">
|
||||||
|
<p className="text-sm font-medium">Platzhalter</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Klick fügt an der Schreibmarke ein. Fehlt beim Erzeugen der Wert, bleibt eine
|
||||||
|
sichtbare Lücke — nichts verschwindet still.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{PLATZHALTER.map((platzhalter) => (
|
||||||
|
<li key={platzhalter.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => platzhalterEinfuegen(platzhalter.name)}
|
||||||
|
className="w-full rounded px-2 py-1 text-left text-xs hover:bg-accent"
|
||||||
|
title={platzhalter.quelle}
|
||||||
|
>
|
||||||
|
<code className="text-primary">{'{{'}{platzhalter.name}{'}}'}</code>
|
||||||
|
<span className="block text-muted-foreground">
|
||||||
|
{platzhalter.beschriftung}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Select } from '@/components/ui/select'
|
import { Select } from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Feld, Feldgruppe } from '@/components/feld'
|
import { Feld, Feldgruppe } from '@/components/feld'
|
||||||
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte'
|
import {
|
||||||
|
KONTAKT_ANREDEN,
|
||||||
|
KONTAKT_ROLLEN,
|
||||||
|
KONTAKT_ROLLE_BESCHRIFTUNG,
|
||||||
|
} from '@/lib/validierung/kontakte'
|
||||||
|
import { ANREDE_BESCHRIFTUNG } from '@/lib/kontakte/anrede'
|
||||||
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
|
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +29,7 @@ export type KontaktWerte = {
|
|||||||
name: string
|
name: string
|
||||||
organisation: string | null
|
organisation: string | null
|
||||||
rolle: string
|
rolle: string
|
||||||
|
anrede: string
|
||||||
telefon: string | null
|
telefon: string | null
|
||||||
mobil: string | null
|
mobil: string | null
|
||||||
fax: string | null
|
fax: string | null
|
||||||
@@ -91,6 +97,21 @@ export function KontaktFormular({
|
|||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="anrede"
|
||||||
|
beschriftung="Anrede"
|
||||||
|
erklaerung="Für die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet — geraten wird nie."
|
||||||
|
fehler={zustand.felder?.anrede}
|
||||||
|
>
|
||||||
|
<Select name="anrede" defaultValue={wert('anrede') || 'OHNE'}>
|
||||||
|
{KONTAKT_ANREDEN.map((anrede) => (
|
||||||
|
<option key={anrede} value={anrede}>
|
||||||
|
{ANREDE_BESCHRIFTUNG[anrede]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="aktenzeichen"
|
id="aktenzeichen"
|
||||||
beschriftung="Aktenzeichen dort"
|
beschriftung="Aktenzeichen dort"
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
FileDown,
|
||||||
|
FolderInput,
|
||||||
|
Info,
|
||||||
|
Mail,
|
||||||
|
UserPlus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Select } from '@/components/ui/select'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||||
|
import {
|
||||||
|
kontaktAusAnschrift,
|
||||||
|
schreibenAblegen,
|
||||||
|
type SchreibenZustand,
|
||||||
|
} from '@/server/actions/schreiben'
|
||||||
|
import type { SchreibenArt } from '@/lib/schreiben/arten'
|
||||||
|
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
|
||||||
|
|
||||||
|
export type KontaktAuswahl = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
organisation: string | null
|
||||||
|
strasse: string | null
|
||||||
|
plz: string | null
|
||||||
|
ort: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreiben bearbeiten, herunterladen, ablegen.
|
||||||
|
*
|
||||||
|
* Der Text steht in einem gewöhnlichen Textfeld und ist vollständig
|
||||||
|
* bearbeitbar. Das ist Absicht: Jeder Fall hat eine Besonderheit, die in
|
||||||
|
* den Brief gehört. Ein Formulartext, den man nicht mehr ändern kann,
|
||||||
|
* wird früher oder später falsch abgeschickt.
|
||||||
|
*/
|
||||||
|
export function SchreibenEditor({
|
||||||
|
betreuteId,
|
||||||
|
art,
|
||||||
|
entwurf,
|
||||||
|
kontakte,
|
||||||
|
vertragId,
|
||||||
|
vorgangId,
|
||||||
|
fristBegruendung,
|
||||||
|
warnungen,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
art: SchreibenArt
|
||||||
|
entwurf: {
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
empfaengerName: string
|
||||||
|
empfaengerAnschrift: string
|
||||||
|
wiedervorlageAm: string
|
||||||
|
}
|
||||||
|
kontakte: KontaktAuswahl[]
|
||||||
|
vertragId: string | null
|
||||||
|
vorgangId: string | null
|
||||||
|
fristBegruendung: string | null
|
||||||
|
warnungen: string[]
|
||||||
|
}) {
|
||||||
|
const [text, setText] = React.useState(entwurf.text)
|
||||||
|
const [betreff, setBetreff] = React.useState(entwurf.betreff)
|
||||||
|
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
|
||||||
|
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState(
|
||||||
|
entwurf.empfaengerAnschrift,
|
||||||
|
)
|
||||||
|
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
|
||||||
|
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
|
||||||
|
const [betreffKopiert, setBetreffKopiert] = React.useState(false)
|
||||||
|
const [mailKopiert, setMailKopiert] = React.useState(false)
|
||||||
|
const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10))
|
||||||
|
const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
|
||||||
|
const [kopiert, setKopiert] = React.useState(false)
|
||||||
|
|
||||||
|
const [ablageZustand, ablegen] = useActionState<SchreibenZustand, FormData>(
|
||||||
|
schreibenAblegen,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
const [kontaktZustand, kontaktAnlegen] = useActionState<SchreibenZustand, FormData>(
|
||||||
|
kontaktAusAnschrift,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Gemeinsame versteckte Felder für beide Formulare und den Download. */
|
||||||
|
function VersteckteFelder() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
<input type="hidden" name="art" value={art} />
|
||||||
|
<input type="hidden" name="betreff" value={betreff} />
|
||||||
|
<input type="hidden" name="text" value={text} />
|
||||||
|
<input type="hidden" name="empfaengerName" value={empfaengerName} />
|
||||||
|
<input type="hidden" name="empfaengerAnschrift" value={empfaengerAnschrift} />
|
||||||
|
<input type="hidden" name="versandAm" value={versandAm} />
|
||||||
|
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
|
||||||
|
<input type="hidden" name="vertragId" value={vertragId ?? ''} />
|
||||||
|
<input type="hidden" name="vorgangId" value={vorgangId ?? ''} />
|
||||||
|
<input type="hidden" name="versandweg" value={versandweg} />
|
||||||
|
<input type="hidden" name="empfaengerEmail" value={empfaengerEmail} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function kontaktUebernehmen(id: string) {
|
||||||
|
const kontakt = kontakte.find((k) => k.id === id)
|
||||||
|
if (!kontakt) return
|
||||||
|
setEmpfaengerName(kontakt.organisation ?? kontakt.name)
|
||||||
|
setEmpfaengerAnschrift(
|
||||||
|
[kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
)
|
||||||
|
setEmpfaengerEmail(kontakt.email ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function textKopieren() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setKopiert(true)
|
||||||
|
window.setTimeout(() => setKopiert(false), 2500)
|
||||||
|
} catch {
|
||||||
|
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const abgelegt = Boolean(ablageZustand.erfolg)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf.
|
||||||
|
*
|
||||||
|
* Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet
|
||||||
|
* hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf.
|
||||||
|
*/
|
||||||
|
function EmailAusgabe() {
|
||||||
|
const fassung = emailFassung(text, betreff)
|
||||||
|
const adresseGueltig = siehtAusWieEmail(empfaengerEmail)
|
||||||
|
const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null
|
||||||
|
|
||||||
|
async function kopieren(inhalt: string, merker: (wert: boolean) => void) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inhalt)
|
||||||
|
merker(true)
|
||||||
|
window.setTimeout(() => merker(false), 2500)
|
||||||
|
} catch {
|
||||||
|
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
|
<Feld
|
||||||
|
id="emailBetreff"
|
||||||
|
beschriftung="Betreff der E-Mail"
|
||||||
|
erklaerung="Aus der Betreffzeile des Briefes übernommen."
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="emailBetreff" value={fassung.betreff} readOnly className="flex-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void kopieren(fassung.betreff, setBetreffKopiert)}
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" />
|
||||||
|
{betreffKopiert ? 'Kopiert' : 'Kopieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="emailText"
|
||||||
|
beschriftung="Text der E-Mail"
|
||||||
|
erklaerung="Ohne Absenderblock, Anschrift und Datumszeile — die gehören in den Brief, nicht in die Mail. Der Nachweis der Bestellung bleibt drin."
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
id="emailText"
|
||||||
|
value={fassung.text}
|
||||||
|
readOnly
|
||||||
|
rows={14}
|
||||||
|
className="font-mono text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void kopieren(fassung.text, setMailKopiert)}
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" />
|
||||||
|
{mailKopiert ? 'Kopiert' : 'Text kopieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{mailto && (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href={mailto}>
|
||||||
|
<Mail aria-hidden="true" />
|
||||||
|
Im Mailprogramm öffnen
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adresseGueltig && (
|
||||||
|
<form action="/schreiben/eml" method="post" className="inline">
|
||||||
|
<VersteckteFelder />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Als E-Mail-Entwurf (.eml)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!adresseGueltig && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Trag oben eine E-Mail-Adresse ein, dann erscheinen hier die Knöpfe fürs
|
||||||
|
Mailprogramm. Kopieren geht auch ohne.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adresseGueltig && !mailto && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Der Text ist zu lang für einen mailto-Link — manche Mailprogramme würden ihn
|
||||||
|
stillschweigend abschneiden. Nimm die .eml-Datei, sie trägt den vollen Text.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{warnungen.length > 0 && (
|
||||||
|
<Alert variant="warnung">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium">Vor dem Abschicken klären:</p>
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||||||
|
{warnungen.map((warnung) => (
|
||||||
|
<li key={warnung}>{warnung}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Empfänger ---------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">An wen geht das Schreiben?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Der Empfänger steht im Brieftext darunter — was du hier einträgst, landet am Dokument
|
||||||
|
in der Akte und im Dateinamen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{kontakte.length > 0 && (
|
||||||
|
<Feld
|
||||||
|
id="kontaktAuswahl"
|
||||||
|
beschriftung="Aus den Kontakten übernehmen"
|
||||||
|
erklaerung="Spart das Abtippen, wenn die Stelle schon im System steht."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
id="kontaktAuswahl"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(ereignis) => kontaktUebernehmen(ereignis.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– Kontakt wählen –</option>
|
||||||
|
{kontakte.map((kontakt) => (
|
||||||
|
<option key={kontakt.id} value={kontakt.id}>
|
||||||
|
{kontakt.organisation ?? kontakt.name}
|
||||||
|
{kontakt.ort ? ` · ${kontakt.ort}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Feld id="empfaengerName" beschriftung="Name oder Gesellschaft" pflicht>
|
||||||
|
<Input
|
||||||
|
id="empfaengerName"
|
||||||
|
value={empfaengerName}
|
||||||
|
onChange={(ereignis) => setEmpfaengerName(ereignis.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="z. B. Allianz Lebensversicherungs-AG"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="empfaengerAnschrift"
|
||||||
|
beschriftung="Anschrift"
|
||||||
|
erklaerung="Straße, dann PLZ und Ort — je Zeile eine Angabe. Nur nötig, wenn du daraus einen Kontakt anlegen willst; in den Brieftext trägst du sie unten selbst ein."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="empfaengerAnschrift"
|
||||||
|
value={empfaengerAnschrift}
|
||||||
|
onChange={(ereignis) => setEmpfaengerAnschrift(ereignis.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="empfaengerEmail"
|
||||||
|
beschriftung="E-Mail-Adresse"
|
||||||
|
erklaerung="Nur nötig, wenn das Schreiben als E-Mail hinausgehen soll. Wird aus dem gewählten Kontakt übernommen."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="empfaengerEmail"
|
||||||
|
type="email"
|
||||||
|
value={empfaengerEmail}
|
||||||
|
onChange={(ereignis) => setEmpfaengerEmail(ereignis.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="service@versicherung.de"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
{empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && (
|
||||||
|
<form action={kontaktAnlegen}>
|
||||||
|
<VersteckteFelder />
|
||||||
|
{kontaktZustand.fehler && (
|
||||||
|
<Alert variant="fehler" className="mb-2">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{kontaktZustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{kontaktZustand.erfolg && (
|
||||||
|
<Alert variant="erfolg" className="mb-2">
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
<AlertDescription>{kontaktZustand.erfolg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Absendeknopf variant="outline" size="sm" wartetext="Wird angelegt …">
|
||||||
|
<UserPlus aria-hidden="true" />
|
||||||
|
Als Kontakt merken
|
||||||
|
</Absendeknopf>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Der Brief ---------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Der Brief</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vollständig bearbeitbar. Was hier steht, steht auch in der Word-Datei und in der
|
||||||
|
Fassung für die Akte.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Feld
|
||||||
|
id="betreff"
|
||||||
|
beschriftung="Betreff"
|
||||||
|
erklaerung="Wird zum Titel des Dokuments in der Akte."
|
||||||
|
pflicht
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="betreff"
|
||||||
|
value={betreff}
|
||||||
|
onChange={(ereignis) => setBetreff(ereignis.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld id="text" beschriftung="Brieftext" pflicht>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(ereignis) => setText(ereignis.target.value)}
|
||||||
|
rows={26}
|
||||||
|
className="font-mono text-sm leading-relaxed"
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => void textKopieren()}>
|
||||||
|
<Copy aria-hidden="true" />
|
||||||
|
{kopiert ? 'Kopiert' : 'Text kopieren'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Ausgabe ------------------------------------------------------ */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Und jetzt?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Wie geht es hinaus? Die Wahl bestimmt auch, wie das Schreiben in der Akte
|
||||||
|
gekennzeichnet wird.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="flex gap-2" role="group" aria-label="Versandweg">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={versandweg === 'BRIEF' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setVersandweg('BRIEF')}
|
||||||
|
>
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Als Brief
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={versandweg === 'EMAIL' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setVersandweg('EMAIL')}
|
||||||
|
>
|
||||||
|
<Mail aria-hidden="true" />
|
||||||
|
Als E-Mail
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versandweg === 'EMAIL' && <EmailAusgabe />}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
id="versandAm"
|
||||||
|
beschriftung="Versandt am"
|
||||||
|
erklaerung="Steht am Dokument in der Akte."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="versandAm"
|
||||||
|
type="date"
|
||||||
|
value={versandAm}
|
||||||
|
onChange={(ereignis) => setVersandAm(ereignis.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="wiedervorlageAm"
|
||||||
|
beschriftung="Wiedervorlage am"
|
||||||
|
erklaerung={fristBegruendung ?? 'Leer lassen, wenn du keine Antwort erwartest.'}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="wiedervorlageAm"
|
||||||
|
type="date"
|
||||||
|
value={wiedervorlageAm}
|
||||||
|
onChange={(ereignis) => setWiedervorlageAm(ereignis.target.value)}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versandweg === 'BRIEF' && (
|
||||||
|
<>
|
||||||
|
{/* Word-Datei: eigene Route, damit der Browser einen echten
|
||||||
|
Download bekommt. */}
|
||||||
|
<form action="/schreiben/docx" method="post" className="inline">
|
||||||
|
<VersteckteFelder />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Als Word-Datei
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<Info aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der
|
||||||
|
Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die
|
||||||
|
Word-Datei ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
|
||||||
|
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab —
|
||||||
|
dann lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann
|
||||||
|
ablegen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ablageZustand.fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{ablageZustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{abgelegt && (
|
||||||
|
<Alert variant="erfolg">
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium">{ablageZustand.erfolg}</p>
|
||||||
|
{ablageZustand.meldung && (
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||||||
|
{ablageZustand.meldung.map((zeile) => (
|
||||||
|
<li key={zeile}>{zeile}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link href="/dokumente?richtung=AUSGANG" className="underline">
|
||||||
|
Zu den ausgehenden Dokumenten
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={ablegen}>
|
||||||
|
<VersteckteFelder />
|
||||||
|
<Absendeknopf disabled={abgelegt} wartetext="Wird abgelegt …">
|
||||||
|
<FolderInput aria-hidden="true" />
|
||||||
|
{abgelegt ? 'Liegt in der Akte' : 'In die Akte legen'}
|
||||||
|
</Absendeknopf>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
import { Select } from '@/components/ui/select'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagenauswahl über dem Schreiben-Editor.
|
||||||
|
*
|
||||||
|
* Die Wahl lädt die Seite mit ?vorlageId neu — der Entwurf entsteht
|
||||||
|
* serverseitig, wo die Platzhalter aufgelöst werden. Ein Wechsel wirft
|
||||||
|
* damit auch bewusst den bearbeiteten Text weg; deshalb steht die Auswahl
|
||||||
|
* VOR dem Editor, nicht darin.
|
||||||
|
*/
|
||||||
|
export function VorlagenWahl({
|
||||||
|
vorlagen,
|
||||||
|
aktiveVorlageId,
|
||||||
|
}: {
|
||||||
|
vorlagen: { id: string; name: string }[]
|
||||||
|
aktiveVorlageId: string | null
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const parameter = useSearchParams()
|
||||||
|
|
||||||
|
function wechseln(vorlageId: string) {
|
||||||
|
const neu = new URLSearchParams(parameter.toString())
|
||||||
|
if (vorlageId) neu.set('vorlageId', vorlageId)
|
||||||
|
else neu.delete('vorlageId')
|
||||||
|
router.push(`/schreiben/neu?${neu.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-md border bg-muted/30 p-3">
|
||||||
|
<FileText className="mt-8 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<Feld
|
||||||
|
id="vorlageWahl"
|
||||||
|
beschriftung="Aus eigener Vorlage"
|
||||||
|
erklaerung="Ersetzt den Entwurf unten — was dort schon steht, geht dabei verloren."
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
id="vorlageWahl"
|
||||||
|
value={aktiveVorlageId ?? ''}
|
||||||
|
onChange={(ereignis) => wechseln(ereignis.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– leerer Entwurf –</option>
|
||||||
|
{vorlagen.map((vorlage) => (
|
||||||
|
<option key={vorlage.id} value={vorlage.id}>
|
||||||
|
{vorlage.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,310 +1,310 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useActionState, useState } from 'react'
|
import { useActionState, useState } from 'react'
|
||||||
import { Feld, Feldgruppe } from '@/components/feld'
|
import { Feld, Feldgruppe } from '@/components/feld'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select } from '@/components/ui/select'
|
import { Select } from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||||
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
|
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
|
||||||
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||||
|
|
||||||
export type HeimkostenWerte = {
|
export type HeimkostenWerte = {
|
||||||
jahr: number
|
jahr: number
|
||||||
monat: number
|
monat: number
|
||||||
pflegeEigenanteil: string
|
pflegeEigenanteil: string
|
||||||
unterkunftVerpflegung: string
|
unterkunftVerpflegung: string
|
||||||
investitionskosten: string
|
investitionskosten: string
|
||||||
ausbildungsumlage: string
|
ausbildungsumlage: string
|
||||||
zusatzleistungen: string
|
zusatzleistungen: string
|
||||||
pflegekasse: string
|
pflegekasse: string
|
||||||
leistungszuschlagProzent: number
|
leistungszuschlagProzent: number
|
||||||
leistungszuschlag: string
|
leistungszuschlag: string
|
||||||
rente: string
|
rente: string
|
||||||
sonstigeEinkuenfte: string
|
sonstigeEinkuenfte: string
|
||||||
sozialhilfe: string
|
sozialhilfe: string
|
||||||
eigenmittel: string
|
eigenmittel: string
|
||||||
barbetrag: string
|
barbetrag: string
|
||||||
bekleidungspauschale: string
|
bekleidungspauschale: string
|
||||||
abwesenheitstage: number
|
abwesenheitstage: number
|
||||||
bemerkung: string
|
bemerkung: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Monat der Gegenüberstellung.
|
* Ein Monat der Gegenüberstellung.
|
||||||
*
|
*
|
||||||
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
|
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
|
||||||
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
|
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
|
||||||
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
|
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
|
||||||
* Stelle, an der es auf der Rechnung steht.
|
* Stelle, an der es auf der Rechnung steht.
|
||||||
*/
|
*/
|
||||||
export function HeimkostenFormular({
|
export function HeimkostenFormular({
|
||||||
werte,
|
werte,
|
||||||
jahre,
|
jahre,
|
||||||
hinweisPflegekasse,
|
hinweisPflegekasse,
|
||||||
vorschlagBarbetrag,
|
vorschlagBarbetrag,
|
||||||
}: {
|
}: {
|
||||||
werte: HeimkostenWerte
|
werte: HeimkostenWerte
|
||||||
jahre: number[]
|
jahre: number[]
|
||||||
/**
|
/**
|
||||||
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
|
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
|
||||||
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
|
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
|
||||||
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
|
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
|
||||||
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
|
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
|
||||||
*/
|
*/
|
||||||
hinweisPflegekasse: string
|
hinweisPflegekasse: string
|
||||||
vorschlagBarbetrag: { betrag: string; text: string }
|
vorschlagBarbetrag: { betrag: string; text: string }
|
||||||
}) {
|
}) {
|
||||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
|
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
|
||||||
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
|
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="space-y-6">
|
<form action={aktion} className="space-y-6">
|
||||||
<Meldung zustand={zustand} />
|
<Meldung zustand={zustand} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Monat erfassen</CardTitle>
|
<CardTitle>Monat erfassen</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
|
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
|
||||||
überschrieben.
|
überschrieben.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
|
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
|
||||||
<Select name="jahr" defaultValue={String(werte.jahr)}>
|
<Select name="jahr" defaultValue={String(werte.jahr)}>
|
||||||
{jahre.map((j) => (
|
{jahre.map((j) => (
|
||||||
<option key={j} value={j}>
|
<option key={j} value={j}>
|
||||||
{j}
|
{j}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
|
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
|
||||||
<Select name="monat" defaultValue={String(werte.monat)}>
|
<Select name="monat" defaultValue={String(werte.monat)}>
|
||||||
{MONATSNAMEN.map((name, i) => (
|
{MONATSNAMEN.map((name, i) => (
|
||||||
<option key={name} value={i + 1}>
|
<option key={name} value={i + 1}>
|
||||||
{name}
|
{name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Feldgruppe
|
<Feldgruppe
|
||||||
titel="Kosten"
|
titel="Kosten"
|
||||||
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
|
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
|
||||||
>
|
>
|
||||||
<Feld
|
<Feld
|
||||||
id="pflegeEigenanteil"
|
id="pflegeEigenanteil"
|
||||||
beschriftung="Einrichtungseinheitlicher Eigenanteil"
|
beschriftung="Einrichtungseinheitlicher Eigenanteil"
|
||||||
fehler={zustand.felder?.pflegeEigenanteil}
|
fehler={zustand.felder?.pflegeEigenanteil}
|
||||||
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
|
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="pflegeEigenanteil"
|
name="pflegeEigenanteil"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.pflegeEigenanteil}
|
defaultValue={werte.pflegeEigenanteil}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="unterkunftVerpflegung"
|
id="unterkunftVerpflegung"
|
||||||
beschriftung="Unterkunft und Verpflegung"
|
beschriftung="Unterkunft und Verpflegung"
|
||||||
fehler={zustand.felder?.unterkunftVerpflegung}
|
fehler={zustand.felder?.unterkunftVerpflegung}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="unterkunftVerpflegung"
|
name="unterkunftVerpflegung"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.unterkunftVerpflegung}
|
defaultValue={werte.unterkunftVerpflegung}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="investitionskosten"
|
id="investitionskosten"
|
||||||
beschriftung="Investitionskosten"
|
beschriftung="Investitionskosten"
|
||||||
fehler={zustand.felder?.investitionskosten}
|
fehler={zustand.felder?.investitionskosten}
|
||||||
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
|
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="investitionskosten"
|
name="investitionskosten"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.investitionskosten}
|
defaultValue={werte.investitionskosten}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="ausbildungsumlage"
|
id="ausbildungsumlage"
|
||||||
beschriftung="Ausbildungsumlage"
|
beschriftung="Ausbildungsumlage"
|
||||||
fehler={zustand.felder?.ausbildungsumlage}
|
fehler={zustand.felder?.ausbildungsumlage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="ausbildungsumlage"
|
name="ausbildungsumlage"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.ausbildungsumlage}
|
defaultValue={werte.ausbildungsumlage}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="zusatzleistungen"
|
id="zusatzleistungen"
|
||||||
beschriftung="Zusatzleistungen"
|
beschriftung="Zusatzleistungen"
|
||||||
fehler={zustand.felder?.zusatzleistungen}
|
fehler={zustand.felder?.zusatzleistungen}
|
||||||
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
|
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="zusatzleistungen"
|
name="zusatzleistungen"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.zusatzleistungen}
|
defaultValue={werte.zusatzleistungen}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
|
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
|
||||||
<Feld
|
<Feld
|
||||||
id="pflegekasse"
|
id="pflegekasse"
|
||||||
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
|
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
|
||||||
fehler={zustand.felder?.pflegekasse}
|
fehler={zustand.felder?.pflegekasse}
|
||||||
erklaerung={hinweisPflegekasse}
|
erklaerung={hinweisPflegekasse}
|
||||||
>
|
>
|
||||||
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
|
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="leistungszuschlagProzent"
|
id="leistungszuschlagProzent"
|
||||||
beschriftung="Leistungszuschlag in Prozent"
|
beschriftung="Leistungszuschlag in Prozent"
|
||||||
fehler={zustand.felder?.leistungszuschlagProzent}
|
fehler={zustand.felder?.leistungszuschlagProzent}
|
||||||
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
|
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="leistungszuschlagProzent"
|
name="leistungszuschlagProzent"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
defaultValue={werte.leistungszuschlagProzent}
|
defaultValue={werte.leistungszuschlagProzent}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="leistungszuschlag"
|
id="leistungszuschlag"
|
||||||
beschriftung="Leistungszuschlag in Euro"
|
beschriftung="Leistungszuschlag in Euro"
|
||||||
fehler={zustand.felder?.leistungszuschlag}
|
fehler={zustand.felder?.leistungszuschlag}
|
||||||
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
|
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="leistungszuschlag"
|
name="leistungszuschlag"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.leistungszuschlag}
|
defaultValue={werte.leistungszuschlag}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
|
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
|
||||||
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
|
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="sonstigeEinkuenfte"
|
id="sonstigeEinkuenfte"
|
||||||
beschriftung="Sonstige Einkünfte"
|
beschriftung="Sonstige Einkünfte"
|
||||||
fehler={zustand.felder?.sonstigeEinkuenfte}
|
fehler={zustand.felder?.sonstigeEinkuenfte}
|
||||||
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
|
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="sonstigeEinkuenfte"
|
name="sonstigeEinkuenfte"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.sonstigeEinkuenfte}
|
defaultValue={werte.sonstigeEinkuenfte}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="sozialhilfe"
|
id="sozialhilfe"
|
||||||
beschriftung="Anteil der Sozialhilfe"
|
beschriftung="Anteil der Sozialhilfe"
|
||||||
fehler={zustand.felder?.sozialhilfe}
|
fehler={zustand.felder?.sozialhilfe}
|
||||||
erklaerung="Was der Bezirk laut Bescheid übernimmt."
|
erklaerung="Was der Bezirk laut Bescheid übernimmt."
|
||||||
>
|
>
|
||||||
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
|
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="eigenmittel"
|
id="eigenmittel"
|
||||||
beschriftung="Eigenmittel"
|
beschriftung="Eigenmittel"
|
||||||
fehler={zustand.felder?.eigenmittel}
|
fehler={zustand.felder?.eigenmittel}
|
||||||
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
|
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
|
||||||
>
|
>
|
||||||
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
|
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feldgruppe
|
<Feldgruppe
|
||||||
titel="Was verbleiben muss"
|
titel="Was verbleiben muss"
|
||||||
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
|
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
|
||||||
>
|
>
|
||||||
<Feld
|
<Feld
|
||||||
id="barbetrag"
|
id="barbetrag"
|
||||||
beschriftung="Barbetrag"
|
beschriftung="Barbetrag"
|
||||||
fehler={zustand.felder?.barbetrag}
|
fehler={zustand.felder?.barbetrag}
|
||||||
erklaerung={
|
erklaerung={
|
||||||
<>
|
<>
|
||||||
{vorschlagBarbetrag.text}{' '}
|
{vorschlagBarbetrag.text}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
|
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
|
||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Wert übernehmen
|
Wert übernehmen
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="barbetrag"
|
name="barbetrag"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={barbetrag}
|
value={barbetrag}
|
||||||
onChange={(e) => setBarbetrag(e.target.value)}
|
onChange={(e) => setBarbetrag(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="bekleidungspauschale"
|
id="bekleidungspauschale"
|
||||||
beschriftung="Bekleidungspauschale"
|
beschriftung="Bekleidungspauschale"
|
||||||
fehler={zustand.felder?.bekleidungspauschale}
|
fehler={zustand.felder?.bekleidungspauschale}
|
||||||
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
|
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="bekleidungspauschale"
|
name="bekleidungspauschale"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.bekleidungspauschale}
|
defaultValue={werte.bekleidungspauschale}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="abwesenheitstage"
|
id="abwesenheitstage"
|
||||||
beschriftung="Abwesenheitstage"
|
beschriftung="Abwesenheitstage"
|
||||||
fehler={zustand.felder?.abwesenheitstage}
|
fehler={zustand.felder?.abwesenheitstage}
|
||||||
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
|
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="abwesenheitstage"
|
name="abwesenheitstage"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={31}
|
max={31}
|
||||||
defaultValue={werte.abwesenheitstage}
|
defaultValue={werte.abwesenheitstage}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
||||||
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
|
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
|
||||||
</Feld>
|
</Feld>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
|
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
|
||||||
|
|
||||||
<Absenden>Monat speichern</Absenden>
|
<Absenden>Monat speichern</Absenden>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
import { useActionState } from 'react'
|
import { useActionState } from 'react'
|
||||||
import { CheckCircle2, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
|
import { CheckCircle2, FileDown, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
@@ -10,6 +11,7 @@ import { Select } from '@/components/ui/select'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Feld } from '@/components/feld'
|
import { Feld } from '@/components/feld'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
|
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
|
||||||
import { kuendigungVermerken } from '@/server/actions/vertraege'
|
import { kuendigungVermerken } from '@/server/actions/vertraege'
|
||||||
import { formatDatum } from '@/lib/utils'
|
import { formatDatum } from '@/lib/utils'
|
||||||
@@ -161,6 +163,15 @@ export function KuendigungsAssistent({
|
|||||||
beschriftung="Kündigungsschreiben"
|
beschriftung="Kündigungsschreiben"
|
||||||
hoehe="min-h-[36rem]"
|
hoehe="min-h-[36rem]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/schreiben/neu?art=KUENDIGUNG&vertragId=${vertragId}`}>
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Bearbeiten, als Word-Datei und in die Akte
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
import { useActionState, useState } from 'react'
|
import { useActionState, useState } from 'react'
|
||||||
import { Coins, Mail, Plus } from 'lucide-react'
|
import { Coins, FileDown, Mail, Plus } from 'lucide-react'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -35,6 +37,7 @@ export function Rueckkaufswert({
|
|||||||
anschreiben,
|
anschreiben,
|
||||||
stichtagVorschlag,
|
stichtagVorschlag,
|
||||||
darfSchreiben,
|
darfSchreiben,
|
||||||
|
alterHinweis,
|
||||||
}: {
|
}: {
|
||||||
betreuteId: string
|
betreuteId: string
|
||||||
vertragId: string
|
vertragId: string
|
||||||
@@ -45,6 +48,8 @@ export function Rueckkaufswert({
|
|||||||
anschreiben: string
|
anschreiben: string
|
||||||
stichtagVorschlag: string
|
stichtagVorschlag: string
|
||||||
darfSchreiben: boolean
|
darfSchreiben: boolean
|
||||||
|
/** Kurztext, wenn der juengste Wert zu alt ist - sonst null. */
|
||||||
|
alterHinweis: string | null
|
||||||
}) {
|
}) {
|
||||||
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
|
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
|
||||||
rueckkaufswertErfassen,
|
rueckkaufswertErfassen,
|
||||||
@@ -54,6 +59,9 @@ export function Rueckkaufswert({
|
|||||||
rueckkaufswertAnfordern,
|
rueckkaufswertAnfordern,
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
// Die Liste kommt bereits juengster Wert zuerst von der Seite.
|
||||||
|
const letzter = werte[0] ?? null
|
||||||
|
|
||||||
const [briefOffen, setBriefOffen] = useState(false)
|
const [briefOffen, setBriefOffen] = useState(false)
|
||||||
const [erfassenOffen, setErfassenOffen] = useState(false)
|
const [erfassenOffen, setErfassenOffen] = useState(false)
|
||||||
|
|
||||||
@@ -87,7 +95,7 @@ export function Rueckkaufswert({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{werte.length === 0 ? (
|
{!letzter ? (
|
||||||
<Leerzustand
|
<Leerzustand
|
||||||
titel="Noch kein Rückkaufswert erfasst"
|
titel="Noch kein Rückkaufswert erfasst"
|
||||||
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
|
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
|
||||||
@@ -102,7 +110,26 @@ export function Rueckkaufswert({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<>
|
||||||
|
{/* Der zuletzt mitgeteilte Wert gehoert nach oben: Wer beim
|
||||||
|
Telefonat mit der Gesellschaft danach gefragt wird, hat
|
||||||
|
keine Zeit, eine Historie zu lesen. */}
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Zuletzt mitgeteilt</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">
|
||||||
|
{formatCent(letzter.wertCent)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
zum Stichtag {formatDatum(letzter.stichtag)} · {letzter.wertquelle}
|
||||||
|
</p>
|
||||||
|
{alterHinweis && (
|
||||||
|
<Badge variant="gelb" className="mt-2">
|
||||||
|
{alterHinweis} — vor dem nächsten Bericht neu anfragen
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Stichtag</TableHead>
|
<TableHead>Stichtag</TableHead>
|
||||||
@@ -125,7 +152,8 @@ export function Rueckkaufswert({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{darfSchreiben && (
|
{darfSchreiben && (
|
||||||
@@ -149,6 +177,15 @@ export function Rueckkaufswert({
|
|||||||
hoehe="min-h-[32rem]"
|
hoehe="min-h-[32rem]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Der Weg, der nicht beim Kopieren endet: bearbeiten,
|
||||||
|
Word-Datei, Ablage in der Akte, Wiedervorlage. */}
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`}>
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Bearbeiten, als Word-Datei und in die Akte
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<form action={anfrageAktion} className="space-y-4" noValidate>
|
<form action={anfrageAktion} className="space-y-4" noValidate>
|
||||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
<input type="hidden" name="vertragId" value={vertragId} />
|
<input type="hidden" name="vertragId" value={vertragId} />
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { AlertCircle, FileArchive, Info } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
|
||||||
|
export type SerienZeile = {
|
||||||
|
vertragId: string
|
||||||
|
bezeichnung: string
|
||||||
|
gesellschaft: string | null
|
||||||
|
artBeschriftung: string
|
||||||
|
moeglich: boolean
|
||||||
|
hindernis: string | null
|
||||||
|
vorausgewaehlt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswahl und Start der Serienabfrage.
|
||||||
|
*
|
||||||
|
* Die Zusammenfassung ("Es entstehen N Schreiben") steht direkt auf dem
|
||||||
|
* Knopf. Man soll den Fehlgriff bemerken, BEVOR zehn Briefe entstehen —
|
||||||
|
* nicht danach beim Aufräumen.
|
||||||
|
*
|
||||||
|
* Der Start läuft als gewöhnliches Formular-POST gegen die Route: Der
|
||||||
|
* Browser bekommt das ZIP als Download, und die Nebenwirkungen (Akte,
|
||||||
|
* Fristen, Anfragedatum) sind serverseitig schon passiert. Nach dem
|
||||||
|
* Klick wird die Seite neu geladen, damit die Sperrvermerke stimmen.
|
||||||
|
*/
|
||||||
|
export function SerienabfrageFormular({
|
||||||
|
betreuteId,
|
||||||
|
zeilen,
|
||||||
|
stichtagVorschlag,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
zeilen: SerienZeile[]
|
||||||
|
stichtagVorschlag: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(
|
||||||
|
() => new Set(zeilen.filter((z) => z.vorausgewaehlt).map((z) => z.vertragId)),
|
||||||
|
)
|
||||||
|
const [stichtag, setStichtag] = React.useState(stichtagVorschlag)
|
||||||
|
const [gestartet, setGestartet] = React.useState(false)
|
||||||
|
|
||||||
|
function umschalten(id: string) {
|
||||||
|
setGewaehlt((bisher) => {
|
||||||
|
const neu = new Set(bisher)
|
||||||
|
if (neu.has(id)) neu.delete(id)
|
||||||
|
else neu.add(id)
|
||||||
|
return neu
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const anzahl = gewaehlt.size
|
||||||
|
|
||||||
|
function beimAbsenden() {
|
||||||
|
// Das Formular lädt als Download - die Seite bleibt stehen. Nach
|
||||||
|
// kurzer Zeit neu laden, damit Sperrvermerke und "Wartet auf andere"
|
||||||
|
// den neuen Stand zeigen.
|
||||||
|
setGestartet(true)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
router.refresh()
|
||||||
|
setGestartet(false)
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action="/vertraege/serienabfrage"
|
||||||
|
method="post"
|
||||||
|
onSubmit={beimAbsenden}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="stichtag"
|
||||||
|
beschriftung="Stichtag der Auskünfte"
|
||||||
|
erklaerung="Zu diesem Tag sollen die Gesellschaften den Rückkaufswert mitteilen — üblicherweise der Stichtag des Vermögensverzeichnisses oder der Beginn des Betreuungsjahres."
|
||||||
|
pflicht
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="stichtag"
|
||||||
|
name="stichtag"
|
||||||
|
type="date"
|
||||||
|
value={stichtag}
|
||||||
|
onChange={(ereignis) => setStichtag(ereignis.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{zeilen.map((zeile) => (
|
||||||
|
<li key={zeile.vertragId} className="flex items-start gap-3 p-3">
|
||||||
|
{zeile.moeglich ? (
|
||||||
|
<Checkbox
|
||||||
|
id={`vertrag-${zeile.vertragId}`}
|
||||||
|
name="vertragId"
|
||||||
|
value={zeile.vertragId}
|
||||||
|
checked={gewaehlt.has(zeile.vertragId)}
|
||||||
|
onChange={() => umschalten(zeile.vertragId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor={zeile.moeglich ? `vertrag-${zeile.vertragId}` : undefined}
|
||||||
|
className={`min-w-0 flex-1 ${zeile.moeglich ? 'cursor-pointer' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{zeile.bezeichnung}</span>
|
||||||
|
<Badge variant="secondary">{zeile.artBeschriftung}</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-muted-foreground">
|
||||||
|
{zeile.gesellschaft ?? 'ohne Gesellschaft'}
|
||||||
|
</span>
|
||||||
|
{zeile.hindernis && (
|
||||||
|
<span className="mt-0.5 block text-xs text-ampel-orange">{zeile.hindernis}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{zeilen.length === 0 && (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<Info aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
Es gibt keine Verträge, die für eine Rückkaufswert-Anfrage in Frage kommen. Lege
|
||||||
|
zuerst die Versicherungen unter <em>Verträge</em> an — oder finde sie über{' '}
|
||||||
|
<em>Vermögen & Kasse → Wiederkehrende Zahlungen</em>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{anzahl === 0 && zeilen.length > 0 && (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>Kein Vertrag gewählt — es entsteht nichts.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button type="submit" disabled={anzahl === 0 || gestartet} size="lg">
|
||||||
|
<FileArchive aria-hidden="true" />
|
||||||
|
{gestartet
|
||||||
|
? 'Wird erzeugt …'
|
||||||
|
: `${anzahl} Schreiben erzeugen und herunterladen`}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Je Vertrag entsteht eine Word-Datei im ZIP, eine PDF-Fassung in der Akte, eine
|
||||||
|
Wiedervorlage nach 28 Tagen — und der Vertrag gilt als angefragt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,237 +1,237 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useActionState, useState } from 'react'
|
import { useActionState, useState } from 'react'
|
||||||
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
|
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select } from '@/components/ui/select'
|
import { Select } from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Feld, Feldgruppe } from '@/components/feld'
|
import { Feld, Feldgruppe } from '@/components/feld'
|
||||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||||
import {
|
import {
|
||||||
BEWERTUNG_BESCHRIFTUNG,
|
BEWERTUNG_BESCHRIFTUNG,
|
||||||
BEWERTUNG_ERKLAERUNG,
|
BEWERTUNG_ERKLAERUNG,
|
||||||
RHYTHMUS_BESCHRIFTUNG,
|
RHYTHMUS_BESCHRIFTUNG,
|
||||||
VERTRAG_ART_BESCHRIFTUNG,
|
VERTRAG_ART_BESCHRIFTUNG,
|
||||||
VERTRAG_STATUS_BESCHRIFTUNG,
|
VERTRAG_STATUS_BESCHRIFTUNG,
|
||||||
} from '@/lib/vertraege/konstanten'
|
} from '@/lib/vertraege/konstanten'
|
||||||
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
|
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
|
||||||
import { vertragSpeichern } from '@/server/actions/vertraege'
|
import { vertragSpeichern } from '@/server/actions/vertraege'
|
||||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||||
|
|
||||||
export type VertragWerte = {
|
export type VertragWerte = {
|
||||||
id?: string
|
id?: string
|
||||||
bezeichnung: string
|
bezeichnung: string
|
||||||
gesellschaft: string
|
gesellschaft: string
|
||||||
art: string
|
art: string
|
||||||
status: string
|
status: string
|
||||||
vertragsnummer: string
|
vertragsnummer: string
|
||||||
beginnAm: string
|
beginnAm: string
|
||||||
endeAm: string
|
endeAm: string
|
||||||
kuendigungsfristMonate: string
|
kuendigungsfristMonate: string
|
||||||
kuendigungZum: string
|
kuendigungZum: string
|
||||||
gekuendigtAm: string
|
gekuendigtAm: string
|
||||||
beitrag: string
|
beitrag: string
|
||||||
rhythmus: string
|
rhythmus: string
|
||||||
bewertungSozialhilfe: string
|
bewertungSozialhilfe: string
|
||||||
bewertungBegruendung: string
|
bewertungBegruendung: string
|
||||||
notizen: string
|
notizen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LEERER_VERTRAG: VertragWerte = {
|
export const LEERER_VERTRAG: VertragWerte = {
|
||||||
bezeichnung: '',
|
bezeichnung: '',
|
||||||
gesellschaft: '',
|
gesellschaft: '',
|
||||||
art: 'LEBENSVERSICHERUNG',
|
art: 'LEBENSVERSICHERUNG',
|
||||||
status: 'AKTIV',
|
status: 'AKTIV',
|
||||||
vertragsnummer: '',
|
vertragsnummer: '',
|
||||||
beginnAm: '',
|
beginnAm: '',
|
||||||
endeAm: '',
|
endeAm: '',
|
||||||
kuendigungsfristMonate: '3',
|
kuendigungsfristMonate: '3',
|
||||||
kuendigungZum: '',
|
kuendigungZum: '',
|
||||||
gekuendigtAm: '',
|
gekuendigtAm: '',
|
||||||
beitrag: '',
|
beitrag: '',
|
||||||
rhythmus: 'MONATLICH',
|
rhythmus: 'MONATLICH',
|
||||||
bewertungSozialhilfe: 'UNGEPRUEFT',
|
bewertungSozialhilfe: 'UNGEPRUEFT',
|
||||||
bewertungBegruendung: '',
|
bewertungBegruendung: '',
|
||||||
notizen: '',
|
notizen: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VertragFormular({
|
export function VertragFormular({
|
||||||
betreuteId,
|
betreuteId,
|
||||||
werte,
|
werte,
|
||||||
}: {
|
}: {
|
||||||
betreuteId: string
|
betreuteId: string
|
||||||
werte: VertragWerte
|
werte: VertragWerte
|
||||||
}) {
|
}) {
|
||||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
|
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
|
||||||
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
|
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
|
||||||
const istNeu = !werte.id
|
const istNeu = !werte.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion} className="space-y-8" noValidate>
|
<form action={aktion} className="space-y-8" noValidate>
|
||||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
|
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
|
||||||
|
|
||||||
<Zustandsmeldung zustand={zustand} />
|
<Zustandsmeldung zustand={zustand} />
|
||||||
|
|
||||||
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
|
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
|
||||||
<Feld
|
<Feld
|
||||||
id="bezeichnung"
|
id="bezeichnung"
|
||||||
beschriftung="Bezeichnung"
|
beschriftung="Bezeichnung"
|
||||||
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
|
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
|
||||||
fehler={zustand.felder?.bezeichnung}
|
fehler={zustand.felder?.bezeichnung}
|
||||||
pflicht
|
pflicht
|
||||||
>
|
>
|
||||||
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
|
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
|
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
|
||||||
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
|
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
|
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
|
||||||
<Select name="art" defaultValue={werte.art}>
|
<Select name="art" defaultValue={werte.art}>
|
||||||
{VERTRAG_ARTEN.map((a) => (
|
{VERTRAG_ARTEN.map((a) => (
|
||||||
<option key={a} value={a}>
|
<option key={a} value={a}>
|
||||||
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
|
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
|
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
|
||||||
<Select name="status" defaultValue={werte.status}>
|
<Select name="status" defaultValue={werte.status}>
|
||||||
{VERTRAG_STATUS.map((s) => (
|
{VERTRAG_STATUS.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
|
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="vertragsnummer"
|
id="vertragsnummer"
|
||||||
beschriftung="Vertragsnummer"
|
beschriftung="Vertragsnummer"
|
||||||
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
|
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
|
||||||
fehler={zustand.felder?.vertragsnummer}
|
fehler={zustand.felder?.vertragsnummer}
|
||||||
className="sm:col-span-2"
|
className="sm:col-span-2"
|
||||||
>
|
>
|
||||||
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
|
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feldgruppe
|
<Feldgruppe
|
||||||
titel="Laufzeit und Kündigung"
|
titel="Laufzeit und Kündigung"
|
||||||
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
|
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
|
||||||
>
|
>
|
||||||
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
|
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
|
||||||
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
|
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="endeAm"
|
id="endeAm"
|
||||||
beschriftung="Vereinbartes Ende"
|
beschriftung="Vereinbartes Ende"
|
||||||
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
|
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
|
||||||
fehler={zustand.felder?.endeAm}
|
fehler={zustand.felder?.endeAm}
|
||||||
>
|
>
|
||||||
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
|
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="kuendigungsfristMonate"
|
id="kuendigungsfristMonate"
|
||||||
beschriftung="Kündigungsfrist in Monaten"
|
beschriftung="Kündigungsfrist in Monaten"
|
||||||
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
|
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
|
||||||
fehler={zustand.felder?.kuendigungsfristMonate}
|
fehler={zustand.felder?.kuendigungsfristMonate}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="kuendigungsfristMonate"
|
name="kuendigungsfristMonate"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={werte.kuendigungsfristMonate}
|
defaultValue={werte.kuendigungsfristMonate}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="kuendigungZum"
|
id="kuendigungZum"
|
||||||
beschriftung="Gekündigt zum"
|
beschriftung="Gekündigt zum"
|
||||||
fehler={zustand.felder?.kuendigungZum}
|
fehler={zustand.felder?.kuendigungZum}
|
||||||
>
|
>
|
||||||
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
|
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
|
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
|
||||||
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
|
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
|
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
|
||||||
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
|
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
|
||||||
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
|
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
|
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
|
||||||
<Select name="rhythmus" defaultValue={werte.rhythmus}>
|
<Select name="rhythmus" defaultValue={werte.rhythmus}>
|
||||||
<option value="">— nicht angegeben —</option>
|
<option value="">— nicht angegeben —</option>
|
||||||
{ZAHL_RHYTHMEN.map((r) => (
|
{ZAHL_RHYTHMEN.map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{RHYTHMUS_BESCHRIFTUNG[r]}
|
{RHYTHMUS_BESCHRIFTUNG[r]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feldgruppe
|
<Feldgruppe
|
||||||
titel="Bewertung fürs Sozialamt"
|
titel="Bewertung fürs Sozialamt"
|
||||||
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
|
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
|
||||||
>
|
>
|
||||||
<Feld
|
<Feld
|
||||||
id="bewertungSozialhilfe"
|
id="bewertungSozialhilfe"
|
||||||
beschriftung="Einordnung"
|
beschriftung="Einordnung"
|
||||||
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
|
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
|
||||||
fehler={zustand.felder?.bewertungSozialhilfe}
|
fehler={zustand.felder?.bewertungSozialhilfe}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
name="bewertungSozialhilfe"
|
name="bewertungSozialhilfe"
|
||||||
value={bewertung}
|
value={bewertung}
|
||||||
onChange={(e) => setBewertung(e.target.value)}
|
onChange={(e) => setBewertung(e.target.value)}
|
||||||
>
|
>
|
||||||
{BEWERTUNGEN.map((b) => (
|
{BEWERTUNGEN.map((b) => (
|
||||||
<option key={b} value={b}>
|
<option key={b} value={b}>
|
||||||
{BEWERTUNG_BESCHRIFTUNG[b]}
|
{BEWERTUNG_BESCHRIFTUNG[b]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="bewertungBegruendung"
|
id="bewertungBegruendung"
|
||||||
beschriftung="Begründung"
|
beschriftung="Begründung"
|
||||||
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
|
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
|
||||||
fehler={zustand.felder?.bewertungBegruendung}
|
fehler={zustand.felder?.bewertungBegruendung}
|
||||||
pflicht={bewertung !== 'UNGEPRUEFT'}
|
pflicht={bewertung !== 'UNGEPRUEFT'}
|
||||||
className="sm:col-span-2"
|
className="sm:col-span-2"
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
name="bewertungBegruendung"
|
name="bewertungBegruendung"
|
||||||
defaultValue={werte.bewertungBegruendung}
|
defaultValue={werte.bewertungBegruendung}
|
||||||
rows={4}
|
rows={4}
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
</Feldgruppe>
|
</Feldgruppe>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="notizen"
|
id="notizen"
|
||||||
beschriftung="Notizen"
|
beschriftung="Notizen"
|
||||||
erklaerung="Wird verschlüsselt gespeichert."
|
erklaerung="Wird verschlüsselt gespeichert."
|
||||||
fehler={zustand.felder?.notizen}
|
fehler={zustand.felder?.notizen}
|
||||||
>
|
>
|
||||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
|
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
|
||||||
|
|
||||||
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
|
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type VorgangsFormularWerte = {
|
|||||||
beschreibung: string | null
|
beschreibung: string | null
|
||||||
gegenstelle: string | null
|
gegenstelle: string | null
|
||||||
aktenzeichen: string | null
|
aktenzeichen: string | null
|
||||||
|
ansprechpartnerId: string | null
|
||||||
verantwortlich: string | null
|
verantwortlich: string | null
|
||||||
naechsterSchritt: string | null
|
naechsterSchritt: string | null
|
||||||
faelligAm: Date | null
|
faelligAm: Date | null
|
||||||
@@ -50,6 +51,7 @@ export function VorgangsFormular({
|
|||||||
vorlageSchluessel,
|
vorlageSchluessel,
|
||||||
werte,
|
werte,
|
||||||
abbrechenHref,
|
abbrechenHref,
|
||||||
|
kontakte,
|
||||||
}: {
|
}: {
|
||||||
modus: 'anlegen' | 'aendern'
|
modus: 'anlegen' | 'aendern'
|
||||||
betreuteId: string
|
betreuteId: string
|
||||||
@@ -57,6 +59,8 @@ export function VorgangsFormular({
|
|||||||
vorlageSchluessel?: string | null
|
vorlageSchluessel?: string | null
|
||||||
werte: VorgangsFormularWerte
|
werte: VorgangsFormularWerte
|
||||||
abbrechenHref: string
|
abbrechenHref: string
|
||||||
|
/** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */
|
||||||
|
kontakte: { id: string; name: string; organisation: string | null }[]
|
||||||
}) {
|
}) {
|
||||||
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
|
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
|
||||||
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
|
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
|
||||||
@@ -146,6 +150,22 @@ export function VorgangsFormular({
|
|||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="ansprechpartnerId"
|
||||||
|
beschriftung="Ansprechpartner"
|
||||||
|
erklaerung="Wer dort die Sache bearbeitet. Schreiben zu diesem Vorgang reden ihn namentlich an — das bringt sie an der Poststelle vorbei."
|
||||||
|
fehler={zustand.felder?.ansprechpartnerId}
|
||||||
|
>
|
||||||
|
<Select name="ansprechpartnerId" defaultValue={werte.ansprechpartnerId ?? ''}>
|
||||||
|
<option value="">– keiner benannt –</option>
|
||||||
|
{kontakte.map((kontakt) => (
|
||||||
|
<option key={kontakt.id} value={kontakt.id}>
|
||||||
|
{[kontakt.name, kontakt.organisation].filter(Boolean).join(' · ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
id="aktenzeichen"
|
id="aktenzeichen"
|
||||||
beschriftung="Aktenzeichen"
|
beschriftung="Aktenzeichen"
|
||||||
|
|||||||
@@ -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
@@ -1,213 +1,213 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import {
|
import {
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
BorderStyle,
|
BorderStyle,
|
||||||
Document,
|
Document,
|
||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
PageNumber,
|
PageNumber,
|
||||||
Packer,
|
Packer,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextRun,
|
TextRun,
|
||||||
convertMillimetersToTwip,
|
convertMillimetersToTwip,
|
||||||
} from 'docx'
|
} from 'docx'
|
||||||
import { formatDatum } from '@/lib/utils'
|
import { formatDatum } from '@/lib/utils'
|
||||||
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
|
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DOCX-Ausgabe.
|
* DOCX-Ausgabe.
|
||||||
*
|
*
|
||||||
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
|
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
|
||||||
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
|
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
|
||||||
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
|
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
|
||||||
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
|
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
|
||||||
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
|
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
|
||||||
*
|
*
|
||||||
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
|
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
|
||||||
* Arbeitsfassung.
|
* Arbeitsfassung.
|
||||||
*
|
*
|
||||||
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
|
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
|
||||||
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
|
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
|
||||||
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
|
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
|
||||||
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
|
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type DocxKopf = {
|
export type DocxKopf = {
|
||||||
titel: string
|
titel: string
|
||||||
betreute: { name: string; geburtsdatum: Date | null }
|
betreute: { name: string; geburtsdatum: Date | null }
|
||||||
behoerde?: string
|
behoerde?: string
|
||||||
aktenzeichen?: string
|
aktenzeichen?: string
|
||||||
zeitraumVon?: Date | null
|
zeitraumVon?: Date | null
|
||||||
zeitraumBis?: Date | null
|
zeitraumBis?: Date | null
|
||||||
stichtag?: Date | null
|
stichtag?: Date | null
|
||||||
rechtsgrundlage?: string
|
rechtsgrundlage?: string
|
||||||
erstelltAm: Date
|
erstelltAm: Date
|
||||||
erstelltVon: string
|
erstelltVon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHRIFT = 'Arial'
|
const SCHRIFT = 'Arial'
|
||||||
|
|
||||||
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
|
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
|
||||||
return new TextRun({
|
return new TextRun({
|
||||||
text,
|
text,
|
||||||
bold: optionen.fett ?? false,
|
bold: optionen.fett ?? false,
|
||||||
color: optionen.grau ? '555555' : '000000',
|
color: optionen.grau ? '555555' : '000000',
|
||||||
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
|
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
|
||||||
font: SCHRIFT,
|
font: SCHRIFT,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function kopfzeilen(kopf: DocxKopf): string[] {
|
function kopfzeilen(kopf: DocxKopf): string[] {
|
||||||
const zeilen: string[] = [
|
const zeilen: string[] = [
|
||||||
`Betreute Person: ${kopf.betreute.name}` +
|
`Betreute Person: ${kopf.betreute.name}` +
|
||||||
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
|
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
|
||||||
]
|
]
|
||||||
if (kopf.behoerde) zeilen.push(kopf.behoerde)
|
if (kopf.behoerde) zeilen.push(kopf.behoerde)
|
||||||
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
|
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
|
||||||
if (kopf.zeitraumVon || kopf.zeitraumBis) {
|
if (kopf.zeitraumVon || kopf.zeitraumBis) {
|
||||||
zeilen.push(
|
zeilen.push(
|
||||||
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
|
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
|
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
|
||||||
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
|
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
|
||||||
return zeilen
|
return zeilen
|
||||||
}
|
}
|
||||||
|
|
||||||
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
|
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
|
||||||
return [
|
return [
|
||||||
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
|
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
|
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
|
||||||
spacing: { after: 500 },
|
spacing: { after: 500 },
|
||||||
}),
|
}),
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile('')],
|
children: [zeile('')],
|
||||||
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
|
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
|
||||||
spacing: { after: 60 },
|
spacing: { after: 60 },
|
||||||
}),
|
}),
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
|
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
|
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
|
||||||
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
|
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
|
||||||
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
|
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
|
||||||
*/
|
*/
|
||||||
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
|
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
|
||||||
const abschnitte = textZuAbschnitten(text)
|
const abschnitte = textZuAbschnitten(text)
|
||||||
|
|
||||||
const inhalt: Paragraph[] = [
|
const inhalt: Paragraph[] = [
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
|
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
|
||||||
spacing: { after: 160 },
|
spacing: { after: 160 },
|
||||||
}),
|
}),
|
||||||
...kopfzeilen(kopf).map(
|
...kopfzeilen(kopf).map(
|
||||||
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
|
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
|
||||||
),
|
),
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile('')],
|
children: [zeile('')],
|
||||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
|
||||||
spacing: { after: 200 },
|
spacing: { after: 200 },
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const abschnitt of abschnitte) {
|
for (const abschnitt of abschnitte) {
|
||||||
if (abschnitt.titel) {
|
if (abschnitt.titel) {
|
||||||
inhalt.push(
|
inhalt.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
|
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
|
||||||
heading: HeadingLevel.HEADING_2,
|
heading: HeadingLevel.HEADING_2,
|
||||||
spacing: { before: 260, after: 100 },
|
spacing: { before: 260, after: 100 },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for (const absatz of abschnitt.absaetze) {
|
for (const absatz of abschnitt.absaetze) {
|
||||||
const istAufzaehlung = absatz.startsWith('•')
|
const istAufzaehlung = absatz.startsWith('•')
|
||||||
inhalt.push(
|
inhalt.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
|
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
|
||||||
bullet: istAufzaehlung ? { level: 0 } : undefined,
|
bullet: istAufzaehlung ? { level: 0 } : undefined,
|
||||||
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
|
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
|
||||||
spacing: { after: 120, line: 300 },
|
spacing: { after: 120, line: 300 },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inhalt.push(...unterschriftenblock(kopf))
|
inhalt.push(...unterschriftenblock(kopf))
|
||||||
|
|
||||||
return new Document({
|
return new Document({
|
||||||
creator: kopf.erstelltVon,
|
creator: kopf.erstelltVon,
|
||||||
title: kopf.titel,
|
title: kopf.titel,
|
||||||
description: `${kopf.titel} für ${kopf.betreute.name}`,
|
description: `${kopf.titel} für ${kopf.betreute.name}`,
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
properties: {
|
properties: {
|
||||||
page: {
|
page: {
|
||||||
margin: {
|
margin: {
|
||||||
top: convertMillimetersToTwip(25),
|
top: convertMillimetersToTwip(25),
|
||||||
bottom: convertMillimetersToTwip(20),
|
bottom: convertMillimetersToTwip(20),
|
||||||
left: convertMillimetersToTwip(25),
|
left: convertMillimetersToTwip(25),
|
||||||
right: convertMillimetersToTwip(20),
|
right: convertMillimetersToTwip(20),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
default: new Header({
|
default: new Header({
|
||||||
children: [
|
children: [
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
alignment: AlignmentType.RIGHT,
|
alignment: AlignmentType.RIGHT,
|
||||||
children: [
|
children: [
|
||||||
zeile(
|
zeile(
|
||||||
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
|
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
|
||||||
{ groesse: 8, grau: true },
|
{ groesse: 8, grau: true },
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
footers: {
|
footers: {
|
||||||
default: new Footer({
|
default: new Footer({
|
||||||
children: [
|
children: [
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
alignment: AlignmentType.CENTER,
|
alignment: AlignmentType.CENTER,
|
||||||
children: [
|
children: [
|
||||||
zeile('Seite ', { groesse: 8, grau: true }),
|
zeile('Seite ', { groesse: 8, grau: true }),
|
||||||
new TextRun({
|
new TextRun({
|
||||||
children: [PageNumber.CURRENT],
|
children: [PageNumber.CURRENT],
|
||||||
size: 16,
|
size: 16,
|
||||||
color: '555555',
|
color: '555555',
|
||||||
font: SCHRIFT,
|
font: SCHRIFT,
|
||||||
}),
|
}),
|
||||||
zeile(' von ', { groesse: 8, grau: true }),
|
zeile(' von ', { groesse: 8, grau: true }),
|
||||||
new TextRun({
|
new TextRun({
|
||||||
children: [PageNumber.TOTAL_PAGES],
|
children: [PageNumber.TOTAL_PAGES],
|
||||||
size: 16,
|
size: 16,
|
||||||
color: '555555',
|
color: '555555',
|
||||||
font: SCHRIFT,
|
font: SCHRIFT,
|
||||||
}),
|
}),
|
||||||
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
|
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
|
||||||
groesse: 8,
|
groesse: 8,
|
||||||
grau: true,
|
grau: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
children: inhalt,
|
children: inhalt,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fertige .docx-Datei als Buffer. */
|
/** Fertige .docx-Datei als Buffer. */
|
||||||
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
|
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
|
||||||
return Packer.toBuffer(docxAusEntwurf(kopf, text))
|
return Packer.toBuffer(docxAusEntwurf(kopf, text))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ function tabellen(betreuteId: string, optionen: VollexportOptionen): Tabelle[] {
|
|||||||
laden: () => db.genehmigung.findMany({ where: { betreuteId } }),
|
laden: () => db.genehmigung.findMany({ where: { betreuteId } }),
|
||||||
},
|
},
|
||||||
{ name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) },
|
{ name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) },
|
||||||
|
{
|
||||||
|
// Eigene Schreibvorlagen gehoeren zur Akte: Sie tragen die im Fall
|
||||||
|
// bewaehrten Formulierungen und waeren beim Umzug sonst verloren.
|
||||||
|
name: 'schreibvorlage',
|
||||||
|
modell: null,
|
||||||
|
laden: () => db.schreibvorlage.findMany({ where: { betreuteId } }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'dokument',
|
name: 'dokument',
|
||||||
modell: 'Dokument',
|
modell: 'Dokument',
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { tageZwischen } from '@/lib/finanzen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie alt ist ein Wert - und ab wann ist das ein Problem?
|
||||||
|
*
|
||||||
|
* Ein Rueckkaufswert ohne Datum ist schlimmer als kein Rueckkaufswert:
|
||||||
|
* Man rechnet mit einer Zahl, die drei Jahre alt sein kann, und merkt es
|
||||||
|
* erst, wenn der Bezirk nachfragt. Deshalb steht neben jedem Betrag der
|
||||||
|
* Stichtag, und ab einem Jahr steht daneben, dass er alt ist.
|
||||||
|
*
|
||||||
|
* Die Grenze liegt bei 365 Tagen, weil der Bezirk jaehrlich prueft und
|
||||||
|
* das Betreuungsgericht jaehrlich Rechnung legen laesst. Ein Wert, der
|
||||||
|
* den letzten Jahresbericht ueberlebt hat, gehoert erneuert.
|
||||||
|
*
|
||||||
|
* Bewusst eine eigene Datei und nicht in vermoegen.ts: Die Regel gilt am
|
||||||
|
* Vertrag genauso wie im Vermoegensverzeichnis, und sie soll an beiden
|
||||||
|
* Stellen dieselbe sein - nicht zweimal als `> 365` hingeschrieben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WERT_VERALTET_TAGE = 365
|
||||||
|
|
||||||
|
export type Wertalter = {
|
||||||
|
/** Tage zwischen Stichtag und heute. Negativ bei kuenftigem Stichtag. */
|
||||||
|
alterTage: number
|
||||||
|
veraltet: boolean
|
||||||
|
/** Kurztext fuer ein Abzeichen neben dem Betrag, sonst null. */
|
||||||
|
hinweis: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist ein Wert dieses Alters erneuerungsbeduerftig? */
|
||||||
|
export function wertVeraltet(alterTage: number): boolean {
|
||||||
|
return alterTage > WERT_VERALTET_TAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Alter eines Werts zum Stichtag.
|
||||||
|
*
|
||||||
|
* Ohne Stichtag gibt es kein Alter - dann null, und die Oberflaeche
|
||||||
|
* zeigt gar nichts an, statt eine Null zu behaupten.
|
||||||
|
*/
|
||||||
|
export function wertalterBestimmen(stichtag: Date | null, heute: Date): Wertalter | null {
|
||||||
|
if (!stichtag) return null
|
||||||
|
|
||||||
|
const alterTage = tageZwischen(stichtag, heute)
|
||||||
|
const veraltet = wertVeraltet(alterTage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
alterTage,
|
||||||
|
veraltet,
|
||||||
|
hinweis: veraltet ? 'über ein Jahr alt' : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ export type Zahlungsgruppe = {
|
|||||||
const RECHTSFORMEN = [
|
const RECHTSFORMEN = [
|
||||||
'gmbh & co kg',
|
'gmbh & co kg',
|
||||||
'gmbh und 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',
|
'gmbh',
|
||||||
'mbh',
|
'mbh',
|
||||||
'ohg',
|
'ohg',
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { KontaktAnrede } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Briefanrede eines Ansprechpartners.
|
||||||
|
*
|
||||||
|
* Ein namentlich adressiertes Schreiben landet bei der Person, die den
|
||||||
|
* Vorgang kennt, statt in der Poststelle. Beim Bezirk Oberfranken ist das
|
||||||
|
* der Unterschied zwischen zwei Wochen und zwei Monaten.
|
||||||
|
*
|
||||||
|
* Zwei Regeln, die hier nicht verhandelbar sind:
|
||||||
|
*
|
||||||
|
* 1. Es wird nie geraten. Ohne hinterlegte Anrede wird mit dem vollen
|
||||||
|
* Namen angeredet ("Sehr geehrte(r) Anna Schmitt,"). Eine falsch
|
||||||
|
* geratene Anrede faellt dem Empfaenger sofort auf und kostet genau
|
||||||
|
* die Aufmerksamkeit, die man gewinnen wollte.
|
||||||
|
* 2. Ohne Ansprechpartner bleibt es bei "Sehr geehrte Damen und Herren,".
|
||||||
|
* Ein halb gefuellter Name darf nie zu "Sehr geehrte Frau ," werden -
|
||||||
|
* lieber unpersoenlich als offensichtlich automatisch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ANREDE_BESCHRIFTUNG: Record<KontaktAnrede, string> = {
|
||||||
|
OHNE: 'keine Angabe',
|
||||||
|
FRAU: 'Frau',
|
||||||
|
HERR: 'Herr',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALLGEMEINE_ANREDE = 'Sehr geehrte Damen und Herren,'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Nachname aus einem vollen Namen.
|
||||||
|
*
|
||||||
|
* Titel und Vornamen fallen weg: "Dr. Anna Schmitt" wird zu "Schmitt".
|
||||||
|
* Doppelnamen mit Bindestrich bleiben ganz, weil sie ein Wort sind.
|
||||||
|
*/
|
||||||
|
export function nachnameAus(name: string): string {
|
||||||
|
const teile = name.trim().split(/\s+/).filter(Boolean)
|
||||||
|
return teile.length > 0 ? teile[teile.length - 1]! : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ansprechpartner = {
|
||||||
|
name: string
|
||||||
|
anrede: KontaktAnrede
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Anredezeile fuer ein Schreiben, einschliesslich Komma.
|
||||||
|
*
|
||||||
|
* Ohne Ansprechpartner - oder mit einem, dessen Name leer ist - die
|
||||||
|
* allgemeine Anrede.
|
||||||
|
*/
|
||||||
|
export function anredeZeile(ansprechpartner: Ansprechpartner | null): string {
|
||||||
|
if (!ansprechpartner) return ALLGEMEINE_ANREDE
|
||||||
|
|
||||||
|
const name = ansprechpartner.name.trim()
|
||||||
|
if (!name) return ALLGEMEINE_ANREDE
|
||||||
|
|
||||||
|
if (ansprechpartner.anrede === 'FRAU') return `Sehr geehrte Frau ${nachnameAus(name)},`
|
||||||
|
if (ansprechpartner.anrede === 'HERR') return `Sehr geehrter Herr ${nachnameAus(name)},`
|
||||||
|
|
||||||
|
return `Sehr geehrte(r) ${name},`
|
||||||
|
}
|
||||||
+214
-214
@@ -1,214 +1,214 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
|
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
|
||||||
import { typErkennen } from '@/lib/speicher/dateityp'
|
import { typErkennen } from '@/lib/speicher/dateityp'
|
||||||
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
|
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
|
||||||
*
|
*
|
||||||
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
|
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
|
||||||
*
|
*
|
||||||
* Zwei Wege fuehren hier zusammen:
|
* Zwei Wege fuehren hier zusammen:
|
||||||
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
|
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
|
||||||
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
|
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
|
||||||
* (Belegmappe, Unterlagenpaket fuer das Gericht)
|
* (Belegmappe, Unterlagenpaket fuer das Gericht)
|
||||||
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
|
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
|
||||||
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
|
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A4 in PDF-Punkten (72 dpi). */
|
/** A4 in PDF-Punkten (72 dpi). */
|
||||||
export const A4_BREITE = 595.28
|
export const A4_BREITE = 595.28
|
||||||
export const A4_HOEHE = 841.89
|
export const A4_HOEHE = 841.89
|
||||||
const RAND = 36
|
const RAND = 36
|
||||||
|
|
||||||
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
|
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
|
||||||
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
|
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
|
||||||
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
|
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
|
||||||
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
|
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
|
||||||
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
|
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
|
||||||
standardschriftenSicherstellen()
|
standardschriftenSicherstellen()
|
||||||
|
|
||||||
// renderToBuffer erwartet ein <Document>-Element; die Typen von
|
// renderToBuffer erwartet ein <Document>-Element; die Typen von
|
||||||
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
// @react-pdf/renderer sind hier enger als React.ReactElement.
|
||||||
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
|
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
|
||||||
return Buffer.from(roh)
|
return Buffer.from(roh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wandelt ein Bild in ein einseitiges PDF.
|
* Wandelt ein Bild in ein einseitiges PDF.
|
||||||
*
|
*
|
||||||
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
|
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
|
||||||
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
|
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
|
||||||
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
|
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
|
||||||
*/
|
*/
|
||||||
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
|
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
|
||||||
const typ = typErkennen(inhalt)
|
const typ = typErkennen(inhalt)
|
||||||
if (!typ || !typ.istBild) {
|
if (!typ || !typ.istBild) {
|
||||||
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
|
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
|
||||||
}
|
}
|
||||||
|
|
||||||
let daten = inhalt
|
let daten = inhalt
|
||||||
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
|
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
|
||||||
|
|
||||||
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
|
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
|
||||||
const sharp = (await import('sharp')).default
|
const sharp = (await import('sharp')).default
|
||||||
daten = await sharp(inhalt).rotate().png().toBuffer()
|
daten = await sharp(inhalt).rotate().png().toBuffer()
|
||||||
format = 'png'
|
format = 'png'
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdf = await PDFDocument.create()
|
const pdf = await PDFDocument.create()
|
||||||
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
|
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
|
||||||
|
|
||||||
const beschriftungshoehe = beschriftung ? 16 : 0
|
const beschriftungshoehe = beschriftung ? 16 : 0
|
||||||
const nutzbareBreite = A4_BREITE - 2 * RAND
|
const nutzbareBreite = A4_BREITE - 2 * RAND
|
||||||
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
|
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
|
||||||
|
|
||||||
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
|
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
|
||||||
const querformat = bild.width > bild.height
|
const querformat = bild.width > bild.height
|
||||||
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
|
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
|
||||||
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
|
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
|
||||||
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
|
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
|
||||||
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
|
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
|
||||||
|
|
||||||
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
|
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
|
||||||
const breite = bild.width * massstab
|
const breite = bild.width * massstab
|
||||||
const hoehe = bild.height * massstab
|
const hoehe = bild.height * massstab
|
||||||
|
|
||||||
const seite = pdf.addPage([seitenBreite, seitenHoehe])
|
const seite = pdf.addPage([seitenBreite, seitenHoehe])
|
||||||
seite.drawImage(bild, {
|
seite.drawImage(bild, {
|
||||||
x: (seitenBreite - breite) / 2,
|
x: (seitenBreite - breite) / 2,
|
||||||
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
|
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
|
||||||
width: breite,
|
width: breite,
|
||||||
height: hoehe,
|
height: hoehe,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (beschriftung) {
|
if (beschriftung) {
|
||||||
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
|
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
|
||||||
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
|
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
|
||||||
x: RAND,
|
x: RAND,
|
||||||
y: RAND / 2,
|
y: RAND / 2,
|
||||||
size: 8,
|
size: 8,
|
||||||
font: schrift,
|
font: schrift,
|
||||||
color: rgb(0.3, 0.3, 0.3),
|
color: rgb(0.3, 0.3, 0.3),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(await pdf.save())
|
return Buffer.from(await pdf.save())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
|
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
|
||||||
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
|
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
|
||||||
*/
|
*/
|
||||||
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
|
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
|
||||||
const ziel = await PDFDocument.create()
|
const ziel = await PDFDocument.create()
|
||||||
ziel.setProducer('Betreuungs-Cockpit')
|
ziel.setProducer('Betreuungs-Cockpit')
|
||||||
ziel.setCreator('Betreuungs-Cockpit')
|
ziel.setCreator('Betreuungs-Cockpit')
|
||||||
|
|
||||||
for (const teil of teile) {
|
for (const teil of teile) {
|
||||||
const typ = typErkennen(teil)
|
const typ = typErkennen(teil)
|
||||||
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
|
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
|
||||||
|
|
||||||
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
|
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
|
||||||
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
|
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
|
||||||
for (const seite of seiten) ziel.addPage(seite)
|
for (const seite of seiten) ziel.addPage(seite)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ziel.getPageCount() === 0) {
|
if (ziel.getPageCount() === 0) {
|
||||||
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
|
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
|
||||||
ziel.addPage([A4_BREITE, A4_HOEHE])
|
ziel.addPage([A4_BREITE, A4_HOEHE])
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(await ziel.save())
|
return Buffer.from(await ziel.save())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
|
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
|
||||||
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
|
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
|
||||||
*/
|
*/
|
||||||
export async function seitenzahl(pdf: Buffer): Promise<number> {
|
export async function seitenzahl(pdf: Buffer): Promise<number> {
|
||||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||||
return dokument.getPageCount()
|
return dokument.getPageCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
|
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
|
||||||
*
|
*
|
||||||
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
|
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
|
||||||
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
|
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
|
||||||
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
|
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
|
||||||
*/
|
*/
|
||||||
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
|
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
|
||||||
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
|
||||||
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
|
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
|
||||||
const seiten = dokument.getPages()
|
const seiten = dokument.getPages()
|
||||||
const gesamt = seiten.length
|
const gesamt = seiten.length
|
||||||
|
|
||||||
seiten.forEach((seite, i) => {
|
seiten.forEach((seite, i) => {
|
||||||
const { width, height } = seite.getSize()
|
const { width, height } = seite.getSize()
|
||||||
const drehung = seite.getRotation().angle % 360
|
const drehung = seite.getRotation().angle % 360
|
||||||
const zaehlung = `Seite ${i + 1} von ${gesamt}`
|
const zaehlung = `Seite ${i + 1} von ${gesamt}`
|
||||||
const text = kuerzenFuerSchrift(fusszeile, 120)
|
const text = kuerzenFuerSchrift(fusszeile, 120)
|
||||||
const groesse = 7.5
|
const groesse = 7.5
|
||||||
|
|
||||||
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
|
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
|
||||||
// gedreht, sonst steht sie quer im Blatt.
|
// gedreht, sonst steht sie quer im Blatt.
|
||||||
if (drehung === 90 || drehung === 270) {
|
if (drehung === 90 || drehung === 270) {
|
||||||
seite.drawText(`${text} · ${zaehlung}`, {
|
seite.drawText(`${text} · ${zaehlung}`, {
|
||||||
x: drehung === 90 ? 18 : width - 18,
|
x: drehung === 90 ? 18 : width - 18,
|
||||||
y: drehung === 90 ? 20 : height - 20,
|
y: drehung === 90 ? 20 : height - 20,
|
||||||
size: groesse,
|
size: groesse,
|
||||||
font: schrift,
|
font: schrift,
|
||||||
color: rgb(0.35, 0.35, 0.35),
|
color: rgb(0.35, 0.35, 0.35),
|
||||||
rotate: degrees(drehung),
|
rotate: degrees(drehung),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
seite.drawText(text, {
|
seite.drawText(text, {
|
||||||
x: 36,
|
x: 36,
|
||||||
y: 18,
|
y: 18,
|
||||||
size: groesse,
|
size: groesse,
|
||||||
font: schrift,
|
font: schrift,
|
||||||
color: rgb(0.35, 0.35, 0.35),
|
color: rgb(0.35, 0.35, 0.35),
|
||||||
})
|
})
|
||||||
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
|
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
|
||||||
seite.drawText(zaehlung, {
|
seite.drawText(zaehlung, {
|
||||||
x: width - 36 - breite,
|
x: width - 36 - breite,
|
||||||
y: 18,
|
y: 18,
|
||||||
size: groesse,
|
size: groesse,
|
||||||
font: schrift,
|
font: schrift,
|
||||||
color: rgb(0.35, 0.35, 0.35),
|
color: rgb(0.35, 0.35, 0.35),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return Buffer.from(await dokument.save())
|
return Buffer.from(await dokument.save())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
|
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
|
||||||
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
|
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
|
||||||
* deshalb hier ersetzen statt riskieren.
|
* deshalb hier ersetzen statt riskieren.
|
||||||
*/
|
*/
|
||||||
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
|
||||||
|
|
||||||
export function fuerStandardschrift(text: string): string {
|
export function fuerStandardschrift(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/[\u2013\u2014]/g, '-')
|
.replace(/[\u2013\u2014]/g, '-')
|
||||||
.replace(/[\u2018\u2019\u201a]/g, "'")
|
.replace(/[\u2018\u2019\u201a]/g, "'")
|
||||||
.replace(/[\u201c\u201d\u201e]/g, '"')
|
.replace(/[\u201c\u201d\u201e]/g, '"')
|
||||||
.replace(/\u2026/g, '...')
|
.replace(/\u2026/g, '...')
|
||||||
.replace(/\u20ac/g, 'EUR')
|
.replace(/\u20ac/g, 'EUR')
|
||||||
.replace(/[\u00a0\u2007\u202f]/g, ' ')
|
.replace(/[\u00a0\u2007\u202f]/g, ' ')
|
||||||
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
|
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
|
||||||
// Speichern des PDFs scheitern zu lassen.
|
// Speichern des PDFs scheitern zu lassen.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
|
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
|
||||||
}
|
}
|
||||||
|
|
||||||
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
|
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
|
||||||
const sauber = fuerStandardschrift(text)
|
const sauber = fuerStandardschrift(text)
|
||||||
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
|
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Die Arten von Schreiben, die die Anwendung erzeugen kann.
|
||||||
|
*
|
||||||
|
* Rein und ohne Datenbank - der Entwurfstext entsteht anderswo
|
||||||
|
* (src/lib/schreiben/entwurf.ts), hier steht nur, was eine Art ausmacht.
|
||||||
|
*
|
||||||
|
* Warum ueberhaupt eine Aufzaehlung? Damit die Wiedervorlagefrist,
|
||||||
|
* die Beschriftung und der Dokumenttitel an EINER Stelle stehen. Vorher
|
||||||
|
* lagen sie verstreut: 28 Tage in rueckkaufswert.ts, 42 in
|
||||||
|
* sachstandsanfrage.ts, die Beschriftungen jeweils in der Seite.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SchreibenArt = 'RUECKKAUFSWERT' | 'KUENDIGUNG' | 'SACHSTAND' | 'FREI'
|
||||||
|
|
||||||
|
export const SCHREIBEN_ARTEN: readonly SchreibenArt[] = [
|
||||||
|
'RUECKKAUFSWERT',
|
||||||
|
'KUENDIGUNG',
|
||||||
|
'SACHSTAND',
|
||||||
|
'FREI',
|
||||||
|
]
|
||||||
|
|
||||||
|
export type SchreibenEigenschaften = {
|
||||||
|
beschriftung: string
|
||||||
|
/**
|
||||||
|
* Vorgeschlagene Wiedervorlage in Tagen.
|
||||||
|
*
|
||||||
|
* null heisst: keine vorschlagen. Bei einem freien Schreiben weiss
|
||||||
|
* niemand, ob und wann eine Antwort kommt - eine erfundene Frist waere
|
||||||
|
* schlechter als keine.
|
||||||
|
*/
|
||||||
|
wiedervorlageTage: number | null
|
||||||
|
/** Woran haengt das Schreiben? Bestimmt, was die Seite laden muss. */
|
||||||
|
bezug: 'VERTRAG' | 'VORGANG' | 'KEINER'
|
||||||
|
/** Kurze Erklaerung, warum die Frist so lang ist. */
|
||||||
|
fristBegruendung: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHREIBEN_EIGENSCHAFTEN: Record<SchreibenArt, SchreibenEigenschaften> = {
|
||||||
|
RUECKKAUFSWERT: {
|
||||||
|
beschriftung: 'Auskunft über Rückkaufswert',
|
||||||
|
// Dieselbe Zahl wie RUECKKAUFSWERT_WIEDERVORLAGE_TAGE - dort steht sie
|
||||||
|
// fuer die Anzeige am Vertrag, hier fuer die erzeugte Frist. Ein Test
|
||||||
|
// haelt beide zusammen.
|
||||||
|
wiedervorlageTage: 28,
|
||||||
|
bezug: 'VERTRAG',
|
||||||
|
fristBegruendung:
|
||||||
|
'Vier Wochen sind bei Versicherern die übliche Bearbeitungszeit. Kommt bis dahin ' +
|
||||||
|
'nichts, lohnt das Nachfassen.',
|
||||||
|
},
|
||||||
|
KUENDIGUNG: {
|
||||||
|
beschriftung: 'Kündigung',
|
||||||
|
// Kuerzer als beim Rueckkaufswert: Eine Kuendigung muss bestaetigt
|
||||||
|
// werden, und ohne Bestaetigung ist nicht belegt, dass sie ankam.
|
||||||
|
wiedervorlageTage: 21,
|
||||||
|
bezug: 'VERTRAG',
|
||||||
|
fristBegruendung:
|
||||||
|
'Drei Wochen. Eine Kündigung ohne schriftliche Bestätigung ist nicht nachgewiesen — ' +
|
||||||
|
'und wenn sie untergegangen ist, läuft die Frist weiter.',
|
||||||
|
},
|
||||||
|
SACHSTAND: {
|
||||||
|
beschriftung: 'Sachstandsanfrage',
|
||||||
|
wiedervorlageTage: 42,
|
||||||
|
bezug: 'VORGANG',
|
||||||
|
fristBegruendung:
|
||||||
|
'Sechs Wochen. Behörden brauchen länger als Versicherer; früher nachzufassen bringt ' +
|
||||||
|
'meist nur die Antwort, dass es noch dauert.',
|
||||||
|
},
|
||||||
|
FREI: {
|
||||||
|
beschriftung: 'Freies Schreiben',
|
||||||
|
wiedervorlageTage: null,
|
||||||
|
bezug: 'KEINER',
|
||||||
|
fristBegruendung: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist der Wert eine bekannte Art? Fuer Parameter aus der Adresszeile. */
|
||||||
|
export function istSchreibenArt(wert: string | null | undefined): wert is SchreibenArt {
|
||||||
|
return typeof wert === 'string' && (SCHREIBEN_ARTEN as readonly string[]).includes(wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateiname fuer die Word-Datei und die Ablage.
|
||||||
|
*
|
||||||
|
* Sprechend, sortierbar, ohne Zeichen, die ein Dateisystem nicht mag:
|
||||||
|
* "2026-08-23_Kuendigung_Allianz.docx"
|
||||||
|
*/
|
||||||
|
export function dateinameFuerSchreiben(
|
||||||
|
art: SchreibenArt,
|
||||||
|
empfaenger: string | null,
|
||||||
|
datum: Date,
|
||||||
|
endung: 'docx' | 'pdf' | 'eml',
|
||||||
|
): string {
|
||||||
|
const tag = datum.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const sauber = (text: string) =>
|
||||||
|
text
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/Ä/g, 'Ae')
|
||||||
|
.replace(/Ö/g, 'Oe')
|
||||||
|
.replace(/Ü/g, 'Ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40)
|
||||||
|
|
||||||
|
const teile = [tag, sauber(SCHREIBEN_EIGENSCHAFTEN[art].beschriftung)]
|
||||||
|
const ziel = empfaenger ? sauber(empfaenger) : ''
|
||||||
|
if (ziel) teile.push(ziel)
|
||||||
|
|
||||||
|
return `${teile.join('_')}.${endung}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import {
|
||||||
|
AlignmentType,
|
||||||
|
Document,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
convertMillimetersToTwip,
|
||||||
|
} from 'docx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Brief als Word-Datei.
|
||||||
|
*
|
||||||
|
* Getrennt von src/lib/berichte/docx.ts, und das ist Absicht. Der dortige
|
||||||
|
* Satz baut einen BERICHT: Titel, Kopfzeilen mit betreuter Person,
|
||||||
|
* Aktenzeichen und Berichtszeitraum, am Ende ein Unterschriftenblock. Ein
|
||||||
|
* Brief braucht etwas anderes - Absender, Anschriftenfeld, Datumszeile
|
||||||
|
* rechts, Betreff, Anrede, Grussformel. Beides in eine Funktion zu zwingen
|
||||||
|
* haette beide Ausgaben schlechter gemacht.
|
||||||
|
*
|
||||||
|
* Warum ueberhaupt .docx und nicht nur PDF? Weil jeder Fall eine
|
||||||
|
* Besonderheit hat, die in den Brief gehoert. Wer das Schreiben vor dem
|
||||||
|
* Absenden noch anfassen will, braucht eine Datei, die sich anfassen
|
||||||
|
* laesst. Die Fassung fuer die Akte ist ein PDF - siehe
|
||||||
|
* src/lib/schreiben/pdf.tsx.
|
||||||
|
*
|
||||||
|
* Das Layout folgt DIN 5008 lose: Anschriftenfeld oben links, Datum
|
||||||
|
* rechts, Betreff fett. Nicht auf den Millimeter - der Brief geht in einen
|
||||||
|
* gewoehnlichen Umschlag, nicht in ein Fensterkuvert der Behoerde.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHRIFT = 'Arial'
|
||||||
|
const GROESSE = 11
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Entwurfstext kommt als eine Zeichenkette mit Zeilenumbruechen.
|
||||||
|
*
|
||||||
|
* Er ist bereits vollstaendig gesetzt: Absender, Anschrift, Datum,
|
||||||
|
* Betreff, Anrede, Text, Gruss. Diese Funktion formatiert ihn nur - sie
|
||||||
|
* denkt sich nichts dazu und laesst nichts weg. Was der Benutzer im
|
||||||
|
* Browser gesehen hat, steht auch in der Datei.
|
||||||
|
*/
|
||||||
|
function absatz(
|
||||||
|
text: string,
|
||||||
|
optionen: { fett?: boolean; rechts?: boolean; abstandNach?: number } = {},
|
||||||
|
): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
alignment: optionen.rechts ? AlignmentType.RIGHT : AlignmentType.LEFT,
|
||||||
|
spacing: { after: optionen.abstandNach ?? 0, line: 276 },
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text,
|
||||||
|
bold: optionen.fett ?? false,
|
||||||
|
size: GROESSE * 2, // docx rechnet in halben Punkten
|
||||||
|
font: SCHRIFT,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt die Betreffzeile, damit sie fett gesetzt werden kann.
|
||||||
|
*
|
||||||
|
* Die erzeugten Schreiben beginnen sie mit "Betreff:" - siehe
|
||||||
|
* betreffZeile() in src/lib/vertraege/anschreiben.ts. Steht dort einmal
|
||||||
|
* etwas anderes, wird die Zeile eben normal gesetzt; ein Brief mit
|
||||||
|
* unfettem Betreff ist immer noch ein brauchbarer Brief.
|
||||||
|
*/
|
||||||
|
function istBetreff(zeile: string): boolean {
|
||||||
|
return /^Betreff:/i.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Datumszeile steht rechts, wie im deutschen Geschaeftsbrief.
|
||||||
|
* Erkennbar daran, dass sie mit einem Ort und Komma oder direkt mit einem
|
||||||
|
* Datum beginnt und sonst nichts enthaelt.
|
||||||
|
*/
|
||||||
|
function istDatumszeile(zeile: string): boolean {
|
||||||
|
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function briefAlsDocx(text: string): Document {
|
||||||
|
const zeilen = text.split('\n')
|
||||||
|
|
||||||
|
const inhalt: Paragraph[] = zeilen.map((zeile) => {
|
||||||
|
if (zeile.trim() === '') return absatz('', { abstandNach: 120 })
|
||||||
|
if (istBetreff(zeile)) return absatz(zeile, { fett: true, abstandNach: 120 })
|
||||||
|
if (istDatumszeile(zeile)) return absatz(zeile, { rechts: true })
|
||||||
|
return absatz(zeile)
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Document({
|
||||||
|
creator: 'Betreuungs-Cockpit',
|
||||||
|
description: 'Schreiben aus der Betreuungsakte',
|
||||||
|
title: 'Schreiben',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
page: {
|
||||||
|
margin: {
|
||||||
|
top: convertMillimetersToTwip(25),
|
||||||
|
right: convertMillimetersToTwip(20),
|
||||||
|
// Links etwas mehr: Da wird gelocht und abgeheftet.
|
||||||
|
left: convertMillimetersToTwip(25),
|
||||||
|
bottom: convertMillimetersToTwip(20),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: inhalt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fertige .docx-Datei als Buffer. */
|
||||||
|
export async function briefDocxErzeugen(text: string): Promise<Buffer> {
|
||||||
|
return Packer.toBuffer(briefAlsDocx(text))
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Aus dem Brieftext eine E-Mail machen.
|
||||||
|
*
|
||||||
|
* Rein und ohne Datenbank, damit sich jede Regel einzeln testen laesst -
|
||||||
|
* und ohne "server-only", weil die mailto-Pruefung auch im Browser
|
||||||
|
* gebraucht wird.
|
||||||
|
*
|
||||||
|
* Der Kern ist emailFassung(): Sie nimmt den fertigen Brieftext und
|
||||||
|
* entfernt, was in einer E-Mail nichts verloren hat - Absenderblock,
|
||||||
|
* Anschriftenfeld, Datumszeile, Betreffzeile (der Betreff steht im
|
||||||
|
* Betrefffeld der Mail). Was bleibt, ist Anrede, Inhalt samt
|
||||||
|
* Bestellungsabsatz und Grussformel.
|
||||||
|
*
|
||||||
|
* Warum aus dem Brieftext ableiten statt getrennt erzeugen? Weil der
|
||||||
|
* Benutzer den Brief womoeglich schon bearbeitet hat. Eine getrennt
|
||||||
|
* erzeugte E-Mail wuerfe diese Aenderungen weg - und dann stuende im
|
||||||
|
* Brief etwas anderes als in der Mail, ohne dass es jemand merkt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab wann ist ein mailto-Link nicht mehr verlaesslich?
|
||||||
|
*
|
||||||
|
* mailto uebergibt den Text in der Adresszeile. Die Grenzen sind je nach
|
||||||
|
* Browser, Betriebssystem und Mailprogramm verschieden und nirgends
|
||||||
|
* zugesichert; ab etwa 2000 Zeichen wird es unzuverlaessig - unter
|
||||||
|
* Windows schneidet die Uebergabe an das Mailprogramm teils frueher ab.
|
||||||
|
*
|
||||||
|
* 1800 Zeichen NACH der URL-Kodierung lassen etwas Luft. Ein
|
||||||
|
* abgeschnittener Brief ist schlimmer als ein fehlender Knopf: Er faellt
|
||||||
|
* erst beim Empfaenger auf.
|
||||||
|
*/
|
||||||
|
export const MAILTO_HOECHSTLAENGE = 1800
|
||||||
|
|
||||||
|
export type EmailFassung = {
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt die Betreffzeile des Briefes. */
|
||||||
|
function istBetreff(zeile: string): boolean {
|
||||||
|
return /^Betreff:/i.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt die Datumszeile ("Ort, 23.08.2026" oder nur das Datum). */
|
||||||
|
function istDatumszeile(zeile: string): boolean {
|
||||||
|
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erkennt den Beginn des eigentlichen Inhalts: die Anrede. */
|
||||||
|
function istAnrede(zeile: string): boolean {
|
||||||
|
return /^Sehr geehrte/i.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet die E-Mail-Fassung aus dem Brieftext ab.
|
||||||
|
*
|
||||||
|
* Alles VOR der Anrede ist Briefkopf - Absender, Anschrift, Datum,
|
||||||
|
* Betreff - und faellt weg. Der Betreff wird dabei herausgelesen und
|
||||||
|
* getrennt zurueckgegeben; er gehoert ins Betrefffeld, nicht in den
|
||||||
|
* Rumpf.
|
||||||
|
*
|
||||||
|
* Findet sich keine Anrede (der Benutzer hat den Text frei umgebaut),
|
||||||
|
* wird NICHTS entfernt: Lieber ein Briefkopf zu viel in der Mail als
|
||||||
|
* stillschweigend verlorener Inhalt.
|
||||||
|
*/
|
||||||
|
export function emailFassung(brieftext: string, betreffVorgabe = ''): EmailFassung {
|
||||||
|
const zeilen = brieftext.split('\n')
|
||||||
|
|
||||||
|
let betreff = betreffVorgabe
|
||||||
|
const betreffZeile = zeilen.find(istBetreff)
|
||||||
|
if (betreffZeile) {
|
||||||
|
const roh = betreffZeile.replace(/^Betreff:\s*/i, '').trim()
|
||||||
|
if (roh) betreff = roh
|
||||||
|
}
|
||||||
|
|
||||||
|
const anredeIndex = zeilen.findIndex(istAnrede)
|
||||||
|
if (anredeIndex === -1) {
|
||||||
|
// Kein erkennbarer Aufbau - unveraendert lassen, nur den Betreff
|
||||||
|
// nicht doppelt fuehren.
|
||||||
|
const ohneBetreff = zeilen.filter((zeile) => !istBetreff(zeile))
|
||||||
|
return { betreff, text: bereinigen(ohneBetreff) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab der Anrede ist alles Inhalt. Datums- und Betreffzeilen davor sind
|
||||||
|
// damit automatisch draussen; zur Sicherheit filtern wir sie auch im
|
||||||
|
// Rumpf, falls der Text unueblich aufgebaut ist.
|
||||||
|
const rumpf = zeilen
|
||||||
|
.slice(anredeIndex)
|
||||||
|
.filter((zeile) => !istBetreff(zeile) && !istDatumszeile(zeile))
|
||||||
|
|
||||||
|
return { betreff, text: bereinigen(rumpf) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hoechstens eine Leerzeile am Stueck, keine am Anfang oder Ende. */
|
||||||
|
function bereinigen(zeilen: string[]): string {
|
||||||
|
const ergebnis: string[] = []
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
if (zeile.trim() === '' && ergebnis[ergebnis.length - 1]?.trim() === '') continue
|
||||||
|
ergebnis.push(zeile.trimEnd())
|
||||||
|
}
|
||||||
|
while (ergebnis[0]?.trim() === '') ergebnis.shift()
|
||||||
|
while (ergebnis[ergebnis.length - 1]?.trim() === '') ergebnis.pop()
|
||||||
|
return ergebnis.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den mailto-Link - oder gibt null zurueck, wenn er zu lang wuerde.
|
||||||
|
*
|
||||||
|
* null heisst fuer die Oberflaeche: den Knopf gar nicht erst anbieten.
|
||||||
|
* Ein Knopf, der einen halben Brief uebergibt, waere schlimmer als
|
||||||
|
* keiner - der Verlust faellt erst beim Empfaenger auf.
|
||||||
|
*/
|
||||||
|
export function mailtoLink(
|
||||||
|
adresse: string,
|
||||||
|
fassung: EmailFassung,
|
||||||
|
): string | null {
|
||||||
|
if (!adresse.trim()) return null
|
||||||
|
|
||||||
|
const link =
|
||||||
|
`mailto:${encodeURIComponent(adresse.trim())}` +
|
||||||
|
`?subject=${encodeURIComponent(fassung.betreff)}` +
|
||||||
|
`&body=${encodeURIComponent(fassung.text)}`
|
||||||
|
|
||||||
|
return link.length <= MAILTO_HOECHSTLAENGE ? link : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sehr grobe Formpruefung einer E-Mail-Adresse.
|
||||||
|
*
|
||||||
|
* Bewusst locker: Die Adresse kommt aus den eigenen Kontakten oder wird
|
||||||
|
* gleich im Mailprogramm sichtbar. Eine strenge Pruefung wuerde nur
|
||||||
|
* gueltige, aber ungewoehnliche Adressen abweisen.
|
||||||
|
*/
|
||||||
|
export function siehtAusWieEmail(wert: string | null | undefined): boolean {
|
||||||
|
if (!wert) return false
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(wert.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine .eml-Datei bauen - der E-Mail-Entwurf zum Oeffnen im Mailprogramm.
|
||||||
|
*
|
||||||
|
* Warum .eml zusaetzlich zu mailto? mailto uebergibt den Text in der
|
||||||
|
* Adresszeile, und deren Laenge ist nirgends zugesichert. Die Briefe hier
|
||||||
|
* sind lang (Bestellungsabsatz, sechs Auskunftspunkte) - eine .eml-Datei
|
||||||
|
* traegt den vollen Text garantiert, egal wie lang.
|
||||||
|
*
|
||||||
|
* Das Format ist RFC 5322: Kopfzeilen, Leerzeile, Rumpf. Die Kopfzeile
|
||||||
|
* "X-Unsent: 1" bittet das Mailprogramm, die Datei als ENTWURF zu oeffnen
|
||||||
|
* statt als empfangene Nachricht - Outlook und Thunderbird respektieren
|
||||||
|
* das. Ein Programm, das sie ignoriert, zeigt die Mail eben als Nachricht
|
||||||
|
* mit Antwort-Moeglichkeit; auch damit laesst sich arbeiten.
|
||||||
|
*
|
||||||
|
* Ohne Absenderadresse ("From"): Die traegt das Mailprogramm beim Senden
|
||||||
|
* selbst ein. Eine erfundene Adresse hier wuerde je nach Programm sogar
|
||||||
|
* uebernommen - und dann ginge die Mail mit falschem Absender hinaus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kodiert Text als quoted-printable (RFC 2045).
|
||||||
|
*
|
||||||
|
* Noetig fuer die Umlaute: .eml ist ein Textformat mit ASCII-Erwartung.
|
||||||
|
* Base64 ginge auch, aber quoted-printable bleibt lesbar, wenn jemand die
|
||||||
|
* Datei im Editor oeffnet - und bei einem Schreiben in einer
|
||||||
|
* Betreuungssache ist Nachvollziehbarkeit ein Wert fuer sich.
|
||||||
|
*/
|
||||||
|
export function quotedPrintable(text: string): string {
|
||||||
|
const bytes = Buffer.from(text.replace(/\r?\n/g, '\r\n'), 'utf8')
|
||||||
|
let ergebnis = ''
|
||||||
|
let zeilenlaenge = 0
|
||||||
|
|
||||||
|
for (const byte of bytes) {
|
||||||
|
let stueck: string
|
||||||
|
|
||||||
|
if (byte === 0x0d || byte === 0x0a) {
|
||||||
|
// Zeilenumbruch unkodiert - er strukturiert die Mail.
|
||||||
|
ergebnis += String.fromCharCode(byte)
|
||||||
|
zeilenlaenge = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const istDruckbar =
|
||||||
|
(byte >= 33 && byte <= 126 && byte !== 61) || byte === 32 || byte === 9
|
||||||
|
if (istDruckbar) {
|
||||||
|
stueck = String.fromCharCode(byte)
|
||||||
|
} else {
|
||||||
|
stueck = `=${byte.toString(16).toUpperCase().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 2045: hoechstens 76 Zeichen je Zeile, weiche Umbrueche mit "=".
|
||||||
|
if (zeilenlaenge + stueck.length > 75) {
|
||||||
|
ergebnis += '=\r\n'
|
||||||
|
zeilenlaenge = 0
|
||||||
|
}
|
||||||
|
ergebnis += stueck
|
||||||
|
zeilenlaenge += stueck.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kodiert eine Kopfzeile mit Umlauten (RFC 2047, "encoded word").
|
||||||
|
*
|
||||||
|
* "Betreff: Rückkaufswert" wuerde als rohe Kopfzeile von manchen
|
||||||
|
* Programmen verstuemmelt. ASCII-Betreffs bleiben unkodiert - lesbarer.
|
||||||
|
*/
|
||||||
|
export function kopfzeileKodieren(text: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (!/[^\x20-\x7E]/.test(text)) return text
|
||||||
|
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmlEingabe = {
|
||||||
|
an: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die .eml-Datei als Buffer. */
|
||||||
|
export function emlErzeugen(eingabe: EmlEingabe): Buffer {
|
||||||
|
const kopf = [
|
||||||
|
`To: ${kopfzeileKodieren(eingabe.an)}`,
|
||||||
|
`Subject: ${kopfzeileKodieren(eingabe.betreff)}`,
|
||||||
|
'X-Unsent: 1',
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: quoted-printable',
|
||||||
|
].join('\r\n')
|
||||||
|
|
||||||
|
return Buffer.from(`${kopf}\r\n\r\n${quotedPrintable(eingabe.text)}\r\n`, 'utf8')
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
|
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
|
||||||
|
import {
|
||||||
|
absenderBlock,
|
||||||
|
anschriftBlock,
|
||||||
|
betreffZeile,
|
||||||
|
bestellungsAbsatz,
|
||||||
|
datumsZeile,
|
||||||
|
grussformel,
|
||||||
|
type Briefkopf,
|
||||||
|
type Empfaenger,
|
||||||
|
} from '@/lib/vertraege/anschreiben'
|
||||||
|
import { rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert'
|
||||||
|
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
|
||||||
|
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
|
||||||
|
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
|
||||||
|
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
import { SCHREIBEN_EIGENSCHAFTEN, type SchreibenArt } from '@/lib/schreiben/arten'
|
||||||
|
import { platzhalterErsetzen, type PlatzhalterWerte } from '@/lib/schreiben/platzhalter'
|
||||||
|
import { formatDatum } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den Entwurf eines Schreibens - Text, Betreff, Empfaenger.
|
||||||
|
*
|
||||||
|
* Hier laufen die vorhandenen Erzeuger zusammen (Rueckkaufswert,
|
||||||
|
* Kuendigung, Sachstandsanfrage). Sie bleiben unveraendert: Ihr Wortlaut
|
||||||
|
* ist getestet und in der Praxis erprobt, und ihn fuer eine neue
|
||||||
|
* Ausgabeform umzuschreiben waere ein Risiko ohne Gewinn.
|
||||||
|
*
|
||||||
|
* Was hier dazukommt, ist der vierte Fall: das freie Schreiben an eine
|
||||||
|
* Stelle, zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung
|
||||||
|
* ist das der Normalfall - man fragt ja gerade an, um zu erfahren, ob dort
|
||||||
|
* ueberhaupt etwas besteht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SchreibenEntwurf = {
|
||||||
|
art: SchreibenArt
|
||||||
|
betreff: string
|
||||||
|
/** Vollstaendiger Brieftext, bearbeitbar. */
|
||||||
|
text: string
|
||||||
|
empfaengerName: string
|
||||||
|
empfaengerAnschrift: string
|
||||||
|
/** Vorgeschlagenes Datum der Wiedervorlage, ISO-Tag oder leer. */
|
||||||
|
wiedervorlageAm: string
|
||||||
|
/** Woran haengt das Schreiben? */
|
||||||
|
vertragId: string | null
|
||||||
|
vorgangId: string | null
|
||||||
|
/** Was vor dem Abschicken zu klaeren ist - aus dem Kuendigungsassistenten. */
|
||||||
|
warnungen: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagAlsIsoText(datum: Date): string {
|
||||||
|
return datum.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wiedervorlageVorschlag(art: SchreibenArt, heute: Date): string {
|
||||||
|
const tage = SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage
|
||||||
|
if (tage === null) return ''
|
||||||
|
const ziel = new Date(heute)
|
||||||
|
ziel.setUTCDate(ziel.getUTCDate() + tage)
|
||||||
|
return tagAlsIsoText(ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf eines freien Schreibens.
|
||||||
|
*
|
||||||
|
* Bewusst kurz und mit sichtbaren Luecken in eckigen Klammern. Ein
|
||||||
|
* vorformulierter Text, der fast passt, wird abgeschickt, ohne gelesen zu
|
||||||
|
* werden; eine offensichtliche Luecke nicht.
|
||||||
|
*
|
||||||
|
* Der Bestellungsabsatz steht drin und nicht zur Wahl: Ohne den Nachweis
|
||||||
|
* der Vertretungsmacht kommt die Auskunft nicht, und das faellt erst nach
|
||||||
|
* vier Wochen auf.
|
||||||
|
*/
|
||||||
|
function freierEntwurf(kopf: Briefkopf, empfaenger: Empfaenger, heute: Date): string {
|
||||||
|
return [
|
||||||
|
absenderBlock(kopf),
|
||||||
|
'',
|
||||||
|
anschriftBlock(empfaenger),
|
||||||
|
'',
|
||||||
|
datumsZeile(kopf.betreuerOrt, heute),
|
||||||
|
'',
|
||||||
|
betreffZeile('[Betreff eintragen]', null, kopf.betreuteName, kopf.betreuteGeburtsdatum),
|
||||||
|
'',
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
bestellungsAbsatz(kopf),
|
||||||
|
'',
|
||||||
|
'[Hier steht dein Anliegen.]',
|
||||||
|
'',
|
||||||
|
'Für Rückfragen stehe ich gerne zur Verfügung.',
|
||||||
|
'',
|
||||||
|
grussformel(kopf.betreuerName),
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zieht die Betreffzeile aus einem fertigen Brieftext. */
|
||||||
|
export function betreffAusText(text: string): string {
|
||||||
|
const zeile = text.split('\n').find((z) => /^Betreff:/i.test(z.trim()))
|
||||||
|
return zeile ? zeile.replace(/^Betreff:\s*/i, '').trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntwurfEingabe = {
|
||||||
|
betreuteId: string
|
||||||
|
/** Wer schreibt - mit Anschrift, wenn hinterlegt. */
|
||||||
|
benutzer: Absender
|
||||||
|
art: SchreibenArt
|
||||||
|
vertragId?: string | null
|
||||||
|
vorgangId?: string | null
|
||||||
|
/** Eigene Vorlage als Grundlage des freien Schreibens. */
|
||||||
|
vorlageId?: string | null
|
||||||
|
/** Fuer den Test ueberschreibbar. */
|
||||||
|
heute?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEntwurf> {
|
||||||
|
const heute = eingabe.heute ?? heuteAlsTag()
|
||||||
|
|
||||||
|
const betreute = await db.betreute.findFirst({
|
||||||
|
where: { id: eingabe.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!betreute) throw new Error('Die betreute Person gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
const kopf = briefkopfBauen({
|
||||||
|
betreute: oeffnen('Betreute', betreute),
|
||||||
|
benutzer: eingabe.benutzer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const gemeinsam = {
|
||||||
|
art: eingabe.art,
|
||||||
|
wiedervorlageAm: wiedervorlageVorschlag(eingabe.art, heute),
|
||||||
|
vertragId: eingabe.vertragId ?? null,
|
||||||
|
vorgangId: eingabe.vorgangId ?? null,
|
||||||
|
warnungen: [] as string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vertragsbezogene Schreiben -------------------------------------------
|
||||||
|
if (eingabe.art === 'RUECKKAUFSWERT' || eingabe.art === 'KUENDIGUNG') {
|
||||||
|
if (!eingabe.vertragId) throw new Error('Für dieses Schreiben fehlt der Vertrag.')
|
||||||
|
|
||||||
|
const vertrag = await db.vertrag.findFirst({
|
||||||
|
where: { id: eingabe.vertragId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!vertrag) throw new Error('Diesen Vertrag gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
|
const empfaenger: Empfaenger = { gesellschaft: vertrag.gesellschaft, anschrift: null }
|
||||||
|
const bezeichnung = `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`
|
||||||
|
|
||||||
|
if (eingabe.art === 'RUECKKAUFSWERT') {
|
||||||
|
const text = rueckkaufswertAnschreiben({
|
||||||
|
kopf,
|
||||||
|
empfaenger,
|
||||||
|
vertragsbezeichnung: bezeichnung,
|
||||||
|
vertragsnummer: offen.vertragsnummer,
|
||||||
|
stichtag: heute,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text,
|
||||||
|
betreff: betreffAusText(text),
|
||||||
|
empfaengerName: vertrag.gesellschaft ?? '',
|
||||||
|
empfaengerAnschrift: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paket = kuendigungsassistent({
|
||||||
|
kopf,
|
||||||
|
empfaenger,
|
||||||
|
vertragsbezeichnung: bezeichnung,
|
||||||
|
vertragsnummer: offen.vertragsnummer,
|
||||||
|
art: vertrag.art,
|
||||||
|
laufzeit: {
|
||||||
|
beginnAm: vertrag.beginnAm,
|
||||||
|
endeAm: vertrag.endeAm,
|
||||||
|
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
|
||||||
|
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
|
||||||
|
},
|
||||||
|
kuendigungZum: vertrag.kuendigungZum,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text: paket.schreiben,
|
||||||
|
betreff: betreffAusText(paket.schreiben),
|
||||||
|
empfaengerName: vertrag.gesellschaft ?? '',
|
||||||
|
empfaengerAnschrift: '',
|
||||||
|
warnungen: paket.warnungen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sachstandsanfrage zum Vorgang ----------------------------------------
|
||||||
|
if (eingabe.art === 'SACHSTAND') {
|
||||||
|
if (!eingabe.vorgangId) throw new Error('Für dieses Schreiben fehlt der Vorgang.')
|
||||||
|
|
||||||
|
const vorgang = await db.vorgang.findFirst({
|
||||||
|
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
|
||||||
|
// Der benannte Ansprechpartner, nicht irgendeiner der verknuepften
|
||||||
|
// Kontakte: Wer angeredet wird, ist eine Entscheidung und kein Zufall.
|
||||||
|
include: {
|
||||||
|
ansprechpartner: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
anrede: true,
|
||||||
|
organisation: true,
|
||||||
|
strasse: true,
|
||||||
|
plz: true,
|
||||||
|
ort: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
const anfrage = sachstandsanfrageErzeugen({
|
||||||
|
vorgangTitel: vorgang.titel,
|
||||||
|
gegenstelle: vorgang.gegenstelle,
|
||||||
|
aktenzeichen: vorgang.aktenzeichen,
|
||||||
|
letzterKontaktAm: vorgang.letzterKontaktAm,
|
||||||
|
betreute: {
|
||||||
|
vorname: betreute.vorname,
|
||||||
|
nachname: betreute.nachname,
|
||||||
|
geburtsdatum: betreute.geburtsdatum,
|
||||||
|
},
|
||||||
|
betreuer: {
|
||||||
|
name: eingabe.benutzer.name,
|
||||||
|
aktenzeichenGericht: betreute.aktenzeichenGericht,
|
||||||
|
betreuungsgericht: betreute.betreuungsgericht,
|
||||||
|
},
|
||||||
|
ansprechpartner: vorgang.ansprechpartner,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ist ein Ansprechpartner benannt, kommt seine Anschrift gleich mit -
|
||||||
|
// sie steht im Kontaktverzeichnis und muesste sonst abgetippt werden.
|
||||||
|
const anschrift = vorgang.ansprechpartner
|
||||||
|
? [
|
||||||
|
vorgang.ansprechpartner.strasse,
|
||||||
|
[vorgang.ansprechpartner.plz, vorgang.ansprechpartner.ort]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.trim() || null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text: anfrage.text,
|
||||||
|
betreff: anfrage.betreff,
|
||||||
|
empfaengerName:
|
||||||
|
vorgang.ansprechpartner?.name ?? vorgang.gegenstelle ?? '',
|
||||||
|
empfaengerAnschrift: anschrift,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Freies Schreiben, wahlweise aus eigener Vorlage -----------------------
|
||||||
|
if (eingabe.vorlageId) {
|
||||||
|
const vorlage = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: eingabe.vorlageId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!vorlage) throw new Error('Diese Vorlage gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
// Die Werte, die eine Vorlage kennen kann. Vertragsbezogene bleiben
|
||||||
|
// hier leer und erscheinen als sichtbare Luecke - ein freies Schreiben
|
||||||
|
// hat keinen Vertrag.
|
||||||
|
const werte: PlatzhalterWerte = {
|
||||||
|
betreute_name: kopf.betreuteName,
|
||||||
|
betreute_geburtsdatum: kopf.betreuteGeburtsdatum
|
||||||
|
? formatDatum(kopf.betreuteGeburtsdatum)
|
||||||
|
: 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,
|
||||||
|
bestellungsabsatz: bestellungsAbsatz(kopf),
|
||||||
|
stichtag: formatDatum(heute),
|
||||||
|
datum_heute: formatDatum(heute),
|
||||||
|
}
|
||||||
|
|
||||||
|
const rumpf = platzhalterErsetzen(vorlage.text, werte)
|
||||||
|
const betreffErsetzt = platzhalterErsetzen(vorlage.betreff, werte)
|
||||||
|
|
||||||
|
// Der Briefrahmen kommt dazu - die Vorlage traegt nur den Inhalt.
|
||||||
|
const text = [
|
||||||
|
absenderBlock(kopf),
|
||||||
|
'',
|
||||||
|
anschriftBlock({ gesellschaft: null, anschrift: null }),
|
||||||
|
'',
|
||||||
|
datumsZeile(kopf.betreuerOrt, heute),
|
||||||
|
'',
|
||||||
|
betreffErsetzt.text ? `Betreff: ${betreffErsetzt.text}` : 'Betreff: [Betreff eintragen]',
|
||||||
|
'',
|
||||||
|
rumpf.text,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text,
|
||||||
|
betreff: betreffErsetzt.text,
|
||||||
|
empfaengerName: '',
|
||||||
|
empfaengerAnschrift: '',
|
||||||
|
warnungen:
|
||||||
|
rumpf.unbekannte.length > 0
|
||||||
|
? [
|
||||||
|
`Die Vorlage enthält unbekannte Platzhalter: ${rumpf.unbekannte
|
||||||
|
.map((name) => `{{${name}}}`)
|
||||||
|
.join(', ')}. Sie sind im Text markiert - bitte vor dem Absenden ersetzen.`,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text,
|
||||||
|
betreff: '',
|
||||||
|
empfaengerName: '',
|
||||||
|
empfaengerAnschrift: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Die mitgelieferten Schreibvorlagen - als Platzhalter-Fassung.
|
||||||
|
*
|
||||||
|
* Sie sind Code und damit unveraenderlich; "wiederherstellen" heisst,
|
||||||
|
* sie erneut als Kopie in die eigenen Vorlagen zu holen.
|
||||||
|
*
|
||||||
|
* Bewusst NICHT dieselben Texte wie die fest verdrahteten Erzeuger in
|
||||||
|
* rueckkaufswert.ts und kuendigungsassistent.ts: Die dortigen bauen
|
||||||
|
* fallbezogene Saetze (Kuendigungstermin, Warnungen), die eine Vorlage
|
||||||
|
* nicht leisten kann. Diese hier sind der gemeinsame Kern als
|
||||||
|
* Ausgangspunkt fuer eigene Fassungen - die Spezialerzeuger bleiben der
|
||||||
|
* bessere Weg, wo es sie gibt, und die Oberflaeche fuehrt weiter zuerst
|
||||||
|
* dorthin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MitgelieferteVorlage = {
|
||||||
|
schluessel: string
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MITGELIEFERTE_VORLAGEN: readonly MitgelieferteVorlage[] = [
|
||||||
|
{
|
||||||
|
schluessel: 'auskunftsersuchen',
|
||||||
|
name: 'Auskunftsersuchen',
|
||||||
|
beschreibung: 'Bestehen bei Ihnen Verträge? Der erste Brief an eine unbekannte Stelle.',
|
||||||
|
betreff: 'Auskunftsersuchen — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'zur Ordnung der Vermögensangelegenheiten bitte ich um Auskunft, ob bei',
|
||||||
|
'Ihnen Verträge, Konten oder sonstige Geschäftsbeziehungen für die von mir',
|
||||||
|
'vertretene Person bestehen oder in den letzten Jahren bestanden haben.',
|
||||||
|
'',
|
||||||
|
'Falls ja, bitte ich um eine Aufstellung mit Vertragsnummern, aktuellem',
|
||||||
|
'Stand und den jährlichen Beiträgen bzw. Leistungen.',
|
||||||
|
'',
|
||||||
|
'Für Rückfragen stehe ich gerne zur Verfügung.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'adressaenderung',
|
||||||
|
name: 'Adressänderung',
|
||||||
|
beschreibung: 'Neue Anschrift nach Heimeinzug mitteilen.',
|
||||||
|
betreff: 'Anschriftenänderung — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'die Anschrift der von mir vertretenen Person hat sich geändert. Die neue',
|
||||||
|
'Anschrift lautet:',
|
||||||
|
'',
|
||||||
|
'{{betreute_anschrift}}',
|
||||||
|
'',
|
||||||
|
'Bitte stellen Sie künftige Post zu dieser Sache außerdem an mich als',
|
||||||
|
'rechtlichen Betreuer zu.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'vollmachtsnachweis',
|
||||||
|
name: 'Nachweis der Vertretungsmacht',
|
||||||
|
beschreibung: 'Wenn eine Stelle die Auskunft verweigert: der Nachweis, förmlich.',
|
||||||
|
betreff: 'Nachweis der Vertretungsmacht — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'auf Ihr Schreiben, in dem Sie um einen Nachweis meiner Vertretungsmacht',
|
||||||
|
'bitten, teile ich mit:',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'Eine Kopie der Bestellungsurkunde liegt bei. Das Original stelle ich bei',
|
||||||
|
'Bedarf zur Einsicht — bitte nennen Sie mir dafür einen Termin.',
|
||||||
|
'',
|
||||||
|
'Ich bitte nunmehr um Bearbeitung meines Anliegens vom {{datum_heute}}.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'rueckkaufswert_kurz',
|
||||||
|
name: 'Rückkaufswert (Kurzfassung)',
|
||||||
|
beschreibung:
|
||||||
|
'Die Kurzfassung der Rückkaufswert-Anfrage. Für den vollen Fragenkatalog nimm den Weg über den Vertrag.',
|
||||||
|
betreff:
|
||||||
|
'Auskunft über Rückkaufswert — {{vertrag_bezeichnung}}, Nr. {{vertrag_nummer}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'zu dem oben genannten Vertrag bei {{vertrag_gesellschaft}} bitte ich um',
|
||||||
|
'Mitteilung des Rückkaufswertes einschließlich Überschussanteilen zum',
|
||||||
|
'Stichtag {{stichtag}}, außerdem der beitragsfreien Versicherungssumme.',
|
||||||
|
'',
|
||||||
|
'Da im Verfahren beim Sozialhilfeträger eine Frist läuft, bitte ich um',
|
||||||
|
'Antwort innerhalb von vier Wochen.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||||
|
import { fuerStandardschrift, pdfErzeugen } from '@/lib/pdf/erzeugen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Schreiben als PDF - die Fassung, die in die Akte geht.
|
||||||
|
*
|
||||||
|
* Warum PDF und nicht die Word-Datei?
|
||||||
|
*
|
||||||
|
* Erstens der Ablageschutz: src/lib/speicher/dateityp.ts laesst nur PDF und
|
||||||
|
* Bilder in den Objektspeicher, und zwar nach den Magic Bytes, nicht nach
|
||||||
|
* Endung oder gemeldetem Typ. Eine .docx ist ein ZIP-Archiv; sie
|
||||||
|
* durchzulassen hiesse, jedes ZIP durchzulassen. Diese Pruefung ist eine
|
||||||
|
* bewusste Zusage der Anwendung, und sie fuer eine Bequemlichkeit
|
||||||
|
* aufzuweichen waere der falsche Handel.
|
||||||
|
*
|
||||||
|
* Zweitens die Sache selbst: In der Akte liegt der Nachweis, was
|
||||||
|
* hinausgegangen ist. Dafuer ist ein Format, das sich nicht mehr aendert,
|
||||||
|
* das richtige. Die .docx ist die Arbeitsfassung zum Ausdrucken und
|
||||||
|
* Unterschreiben - sie liegt beim Benutzer, nicht in der Akte.
|
||||||
|
*
|
||||||
|
* Zu beachten: Abgelegt wird der Text, wie er beim Ablegen im Browser
|
||||||
|
* stand. Wer die Word-Datei danach noch aendert, hat in der Akte eine
|
||||||
|
* andere Fassung als im Briefkasten. Die Oberflaeche sagt das dazu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stile = StyleSheet.create({
|
||||||
|
seite: {
|
||||||
|
paddingTop: 70,
|
||||||
|
paddingBottom: 56,
|
||||||
|
paddingLeft: 71, // 25 mm - da wird gelocht
|
||||||
|
paddingRight: 57,
|
||||||
|
fontSize: 10.5,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
color: '#111111',
|
||||||
|
},
|
||||||
|
zeile: { marginBottom: 0 },
|
||||||
|
leer: { height: 9 },
|
||||||
|
betreff: { fontFamily: 'Helvetica-Bold', marginBottom: 4 },
|
||||||
|
datum: { textAlign: 'right' },
|
||||||
|
fuss: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 28,
|
||||||
|
left: 71,
|
||||||
|
right: 57,
|
||||||
|
fontSize: 7.5,
|
||||||
|
color: '#777777',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function istBetreff(zeile: string): boolean {
|
||||||
|
return /^Betreff:/i.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function istDatumszeile(zeile: string): boolean {
|
||||||
|
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Briefdokument({
|
||||||
|
text,
|
||||||
|
erzeugtAm,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
erzeugtAm: Date
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const zeilen = text.split('\n')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={stile.seite}>
|
||||||
|
{zeilen.map((zeile, i) => {
|
||||||
|
if (zeile.trim() === '') return <View key={i} style={stile.leer} />
|
||||||
|
|
||||||
|
const stil = istBetreff(zeile)
|
||||||
|
? [stile.zeile, stile.betreff]
|
||||||
|
: istDatumszeile(zeile)
|
||||||
|
? [stile.zeile, stile.datum]
|
||||||
|
: stile.zeile
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={i} style={stil}>
|
||||||
|
{fuerStandardschrift(zeile)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Text style={stile.fuss} fixed>
|
||||||
|
{fuerStandardschrift(
|
||||||
|
`Aus dem Betreuungs-Cockpit erzeugt am ${erzeugtAm.toLocaleDateString('de-DE')}. ` +
|
||||||
|
'Diese Fassung liegt in der Akte.',
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fertiges PDF als Buffer, fuer die Ablage im Objektspeicher. */
|
||||||
|
export async function briefPdfErzeugen(text: string, erzeugtAm: Date): Promise<Buffer> {
|
||||||
|
return pdfErzeugen(<Briefdokument text={text} erzeugtAm={erzeugtAm} />)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Platzhalter in Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Rein und ohne Datenbank. Der Vertrag ist einfach: {{name}} wird ersetzt,
|
||||||
|
* und zwar nach drei Regeln, die alle demselben Grundsatz folgen -
|
||||||
|
* NICHTS geht stillschweigend schief:
|
||||||
|
*
|
||||||
|
* 1. Bekannter Platzhalter, Daten vorhanden -> Wert eingesetzt.
|
||||||
|
* 2. Bekannter Platzhalter, Daten fehlen -> sichtbare Luecke
|
||||||
|
* "[Aktenzeichen]". Eine stille Leerstelle wuerde erst der Empfaenger
|
||||||
|
* bemerken - oder niemand.
|
||||||
|
* 3. Unbekannter Platzhalter -> markiert als
|
||||||
|
* "[Unbekannter Platzhalter: {{xyz}}]". Kein Abbruch: Ein Tippfehler
|
||||||
|
* in einer Vorlage darf das Schreiben nicht verhindern, aber er muss
|
||||||
|
* ins Auge springen. Falsch ausgefuellt ist schlimmer als
|
||||||
|
* offensichtlich leer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlatzhalterName =
|
||||||
|
| 'betreute_name'
|
||||||
|
| 'betreute_geburtsdatum'
|
||||||
|
| 'betreute_anschrift'
|
||||||
|
| 'betreuer_name'
|
||||||
|
| 'betreuer_anschrift'
|
||||||
|
| 'betreuer_ort'
|
||||||
|
| 'betreuungsgericht'
|
||||||
|
| 'aktenzeichen_gericht'
|
||||||
|
| 'bestellung_datum'
|
||||||
|
| 'bestellungsabsatz'
|
||||||
|
| 'vertrag_gesellschaft'
|
||||||
|
| 'vertrag_nummer'
|
||||||
|
| 'vertrag_bezeichnung'
|
||||||
|
| 'stichtag'
|
||||||
|
| 'datum_heute'
|
||||||
|
|
||||||
|
export type PlatzhalterBeschreibung = {
|
||||||
|
name: PlatzhalterName
|
||||||
|
beschriftung: string
|
||||||
|
/** Woher der Wert kommt - steht in der Hilfe neben dem Editor. */
|
||||||
|
quelle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die verfuegbaren Platzhalter, in der Reihenfolge der Anzeige.
|
||||||
|
*
|
||||||
|
* Diese Liste steht beim Bearbeiten NEBEN dem Editor - niemand soll
|
||||||
|
* Platzhalternamen raten muessen.
|
||||||
|
*/
|
||||||
|
export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
|
||||||
|
{ name: 'betreute_name', beschriftung: 'Name der betreuten Person', quelle: 'Stammdaten' },
|
||||||
|
{ 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' },
|
||||||
|
{
|
||||||
|
name: 'bestellungsabsatz',
|
||||||
|
beschriftung: 'Der ganze Bestellungsabsatz',
|
||||||
|
quelle: 'Aus Gericht, Aktenzeichen und Bestellungsdatum gebaut',
|
||||||
|
},
|
||||||
|
{ name: 'vertrag_gesellschaft', beschriftung: 'Gesellschaft', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'vertrag_nummer', beschriftung: 'Vertragsnummer', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'vertrag_bezeichnung', beschriftung: 'Vertragsbezeichnung', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'stichtag', beschriftung: 'Stichtag', quelle: 'Beim Erzeugen gewählt' },
|
||||||
|
{ name: 'datum_heute', beschriftung: 'Heutiges Datum', quelle: 'Beim Erzeugen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BEKANNT = new Set<string>(PLATZHALTER.map((p) => p.name))
|
||||||
|
|
||||||
|
/** Die sichtbare Luecke, wenn Daten fehlen: "[Aktenzeichen des Gerichts]". */
|
||||||
|
function luecke(name: PlatzhalterName): string {
|
||||||
|
const eintrag = PLATZHALTER.find((p) => p.name === name)
|
||||||
|
return `[${eintrag?.beschriftung ?? name}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatzhalterWerte = Partial<Record<PlatzhalterName, string | null>>
|
||||||
|
|
||||||
|
export type ErsetzungsErgebnis = {
|
||||||
|
text: string
|
||||||
|
/** Bekannte Platzhalter ohne Daten - als Luecke eingesetzt. */
|
||||||
|
luecken: PlatzhalterName[]
|
||||||
|
/** Unbekannte Platzhalter - markiert im Text. */
|
||||||
|
unbekannte: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet alle {{...}}-Vorkommen. Grosszuegig beim Namen: auch Tippfehler
|
||||||
|
* sollen gefunden (und dann markiert) werden. */
|
||||||
|
const MUSTER = /\{\{\s*([a-zA-Z0-9_äöüÄÖÜß-]+)\s*\}\}/g
|
||||||
|
|
||||||
|
export function platzhalterErsetzen(
|
||||||
|
vorlage: string,
|
||||||
|
werte: PlatzhalterWerte,
|
||||||
|
): ErsetzungsErgebnis {
|
||||||
|
const luecken = new Set<PlatzhalterName>()
|
||||||
|
const unbekannte = new Set<string>()
|
||||||
|
|
||||||
|
const text = vorlage.replace(MUSTER, (treffer, roh: string) => {
|
||||||
|
const name = roh.trim()
|
||||||
|
|
||||||
|
if (!BEKANNT.has(name)) {
|
||||||
|
unbekannte.add(name)
|
||||||
|
return `[Unbekannter Platzhalter: {{${name}}}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const wert = werte[name as PlatzhalterName]
|
||||||
|
if (wert === null || wert === undefined || wert.trim() === '') {
|
||||||
|
luecken.add(name as PlatzhalterName)
|
||||||
|
return luecke(name as PlatzhalterName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wert
|
||||||
|
})
|
||||||
|
|
||||||
|
return { text, luecken: [...luecken], unbekannte: [...unbekannte] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Platzhalter benutzt eine Vorlage? Fuer die Anzeige in der Liste. */
|
||||||
|
export function benutztePlatzhalter(vorlage: string): string[] {
|
||||||
|
const gefunden = new Set<string>()
|
||||||
|
for (const treffer of vorlage.matchAll(MUSTER)) {
|
||||||
|
gefunden.add(treffer[1]!.trim())
|
||||||
|
}
|
||||||
|
return [...gefunden]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlt der Bestellungsabsatz?
|
||||||
|
*
|
||||||
|
* Erfuellt ist die Pruefung durch den Platzhalter {{bestellungsabsatz}}
|
||||||
|
* ODER durch einen von Hand geschriebenen Absatz, der erkennbar die
|
||||||
|
* Bestellung nachweist (Beschluss + Betreuer im selben Text). Wer den
|
||||||
|
* Absatz ausformuliert hat, soll nicht gegaengelt werden.
|
||||||
|
*
|
||||||
|
* Die Warnung ist eine Warnung, kein Verbot: Ein Schreiben an die eigene
|
||||||
|
* Hausverwaltung braucht keinen Bestellungsnachweis. Aber wer ihn
|
||||||
|
* weglaesst, soll es wissentlich tun.
|
||||||
|
*/
|
||||||
|
export function bestellungsabsatzFehlt(vorlage: string): boolean {
|
||||||
|
if (/\{\{\s*bestellungsabsatz\s*\}\}/.test(vorlage)) return false
|
||||||
|
const klein = vorlage.toLowerCase()
|
||||||
|
const nennBestellung =
|
||||||
|
klein.includes('beschluss') && (klein.includes('betreuer') || klein.includes('betreuung'))
|
||||||
|
return !nennBestellung
|
||||||
|
}
|
||||||
+237
-237
@@ -1,237 +1,237 @@
|
|||||||
/**
|
/**
|
||||||
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
|
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
|
||||||
*
|
*
|
||||||
* ACHTUNG - EINZIGE STELLE.
|
* ACHTUNG - EINZIGE STELLE.
|
||||||
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
|
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
|
||||||
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
|
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
|
||||||
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
|
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
|
||||||
* gefunden.
|
* gefunden.
|
||||||
*
|
*
|
||||||
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
|
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
|
||||||
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
|
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
|
||||||
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
|
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
|
||||||
* gehören die Werte hier nachgeprüft.
|
* gehören die Werte hier nachgeprüft.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
|
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
|
||||||
export const KONSTANTEN_STAND = '2025-01-01'
|
export const KONSTANTEN_STAND = '2025-01-01'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
|
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
|
||||||
* blendet ab diesem Datum einen Prüfhinweis ein.
|
* blendet ab diesem Datum einen Prüfhinweis ein.
|
||||||
*/
|
*/
|
||||||
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
|
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
|
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
|
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
|
||||||
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
|
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
|
||||||
* Fortschreibung ergab rechnerisch keine Erhöhung).
|
* Fortschreibung ergab rechnerisch keine Erhöhung).
|
||||||
*
|
*
|
||||||
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
|
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
|
||||||
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
|
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
|
||||||
* doppelt gepflegt.
|
* doppelt gepflegt.
|
||||||
*/
|
*/
|
||||||
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
|
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
|
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
|
||||||
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
|
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
|
||||||
* Regelbedarfsstufe 1.
|
* Regelbedarfsstufe 1.
|
||||||
*
|
*
|
||||||
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
|
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
|
||||||
*
|
*
|
||||||
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
|
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
|
||||||
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
|
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
|
||||||
*/
|
*/
|
||||||
export const BARBETRAG_ANTEIL_PROZENT = 27
|
export const BARBETRAG_ANTEIL_PROZENT = 27
|
||||||
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
|
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bekleidungspauschale.
|
* Bekleidungspauschale.
|
||||||
*
|
*
|
||||||
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
|
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
|
||||||
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
|
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
|
||||||
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
|
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
|
||||||
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
|
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
|
||||||
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
|
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
|
||||||
* schädlicher als gar keine.
|
* schädlicher als gar keine.
|
||||||
*/
|
*/
|
||||||
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
|
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
|
||||||
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
|
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
|
||||||
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
|
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
|
||||||
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
|
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Leistungen der Pflegekasse, § 43 SGB XI
|
// Leistungen der Pflegekasse, § 43 SGB XI
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
|
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
|
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
|
||||||
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
|
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
|
||||||
* Pflegeunterstützungs- und -entlastungsgesetz).
|
* Pflegeunterstützungs- und -entlastungsgesetz).
|
||||||
*
|
*
|
||||||
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
|
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
|
||||||
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
|
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
|
||||||
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
|
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
|
||||||
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
|
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
|
||||||
*
|
*
|
||||||
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
|
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
|
||||||
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
|
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
|
||||||
*/
|
*/
|
||||||
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
|
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
|
||||||
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
|
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
|
||||||
2: 80_500n,
|
2: 80_500n,
|
||||||
3: 131_900n,
|
3: 131_900n,
|
||||||
4: 185_500n,
|
4: 185_500n,
|
||||||
5: 209_600n,
|
5: 209_600n,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
|
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
|
||||||
1: '§ 43 Abs. 3 SGB XI',
|
1: '§ 43 Abs. 3 SGB XI',
|
||||||
2: '§ 43 Abs. 2 SGB XI',
|
2: '§ 43 Abs. 2 SGB XI',
|
||||||
3: '§ 43 Abs. 2 SGB XI',
|
3: '§ 43 Abs. 2 SGB XI',
|
||||||
4: '§ 43 Abs. 2 SGB XI',
|
4: '§ 43 Abs. 2 SGB XI',
|
||||||
5: '§ 43 Abs. 2 SGB XI',
|
5: '§ 43 Abs. 2 SGB XI',
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Leistungszuschlag, § 43c SGB XI
|
// Leistungszuschlag, § 43c SGB XI
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
|
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
|
||||||
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
|
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
|
||||||
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
|
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
|
||||||
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
|
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
|
||||||
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
|
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
|
||||||
// zuverlaessig nur an einer Stelle nachgezogen.
|
// zuverlaessig nur an einer Stelle nachgezogen.
|
||||||
//
|
//
|
||||||
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
|
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
|
||||||
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
|
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
|
||||||
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
|
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
|
||||||
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
|
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Abwesenheit (Krankenhaus, Reha, Urlaub)
|
// Abwesenheit (Krankenhaus, Reha, Urlaub)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
|
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
|
||||||
*
|
*
|
||||||
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
|
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
|
||||||
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
|
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
|
||||||
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
|
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
|
||||||
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
|
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
|
||||||
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
|
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
|
||||||
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
|
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
|
||||||
*
|
*
|
||||||
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
|
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
|
||||||
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
|
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
|
||||||
* gilt, steht in der Heimrechnung.
|
* gilt, steht in der Heimrechnung.
|
||||||
*/
|
*/
|
||||||
export const ABWESENHEIT_KARENZTAGE = 3
|
export const ABWESENHEIT_KARENZTAGE = 3
|
||||||
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
|
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
|
||||||
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
|
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Vermögen, § 90 SGB XII
|
// Vermögen, § 90 SGB XII
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
|
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
|
||||||
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
|
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
|
||||||
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
|
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
|
||||||
*
|
*
|
||||||
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
|
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
|
||||||
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
|
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
|
||||||
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
|
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
|
||||||
*/
|
*/
|
||||||
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
|
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
|
||||||
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
|
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
|
||||||
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
|
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
|
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
|
||||||
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
|
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
|
||||||
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
|
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
|
||||||
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
|
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
|
||||||
* notariellen Übertragungen der letzten zehn Jahre.
|
* notariellen Übertragungen der letzten zehn Jahre.
|
||||||
*/
|
*/
|
||||||
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
|
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Rechtsbehelf
|
// Rechtsbehelf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
|
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
|
||||||
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
|
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
|
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
|
||||||
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
|
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
|
||||||
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
|
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
|
||||||
*/
|
*/
|
||||||
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
|
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
|
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
|
||||||
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
|
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
|
||||||
* (§ 37 Abs. 2 Satz 1 SGB X).
|
* (§ 37 Abs. 2 Satz 1 SGB X).
|
||||||
*/
|
*/
|
||||||
export const ZUGANGSVERMUTUNG_TAGE = 3
|
export const ZUGANGSVERMUTUNG_TAGE = 3
|
||||||
|
|
||||||
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
|
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
|
||||||
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
|
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
|
||||||
|
|
||||||
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
|
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
|
||||||
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
|
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Weitere Leistungen
|
// Weitere Leistungen
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
|
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
|
||||||
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
|
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
|
||||||
* Landespflegegeldgesetz - BayLPflGG).
|
* Landespflegegeldgesetz - BayLPflGG).
|
||||||
*
|
*
|
||||||
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
|
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
|
||||||
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
|
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
|
||||||
* beim Landesamt für Pflege prüfen.
|
* beim Landesamt für Pflege prüfen.
|
||||||
*/
|
*/
|
||||||
export const LANDESPFLEGEGELD_CENT = 100_000n
|
export const LANDESPFLEGEGELD_CENT = 100_000n
|
||||||
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
|
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
|
||||||
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
|
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
|
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
|
||||||
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
|
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
|
||||||
* chronisch Kranken bei 1 %.
|
* chronisch Kranken bei 1 %.
|
||||||
*
|
*
|
||||||
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
|
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
|
||||||
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
|
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
|
||||||
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
|
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
|
||||||
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
|
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
|
||||||
*
|
*
|
||||||
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
|
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
|
||||||
*/
|
*/
|
||||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
|
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
|
||||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
|
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
|
||||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
|
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
|
||||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
|
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
|
||||||
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
|
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
|
||||||
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
|
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
|
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
|
||||||
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
|
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
|
||||||
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
|
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
|
||||||
*/
|
*/
|
||||||
export const RUNDFUNKBEITRAG_CENT = 1_836n
|
export const RUNDFUNKBEITRAG_CENT = 1_836n
|
||||||
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
|
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
|
||||||
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
|
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { KontaktArt, KontaktRolle } from '@prisma/client'
|
import type { KontaktAnrede, KontaktArt, KontaktRolle } from '@prisma/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eingabepruefung fuer Kontakte und Kontaktprotokolle.
|
* Eingabepruefung fuer Kontakte und Kontaktprotokolle.
|
||||||
@@ -101,6 +101,13 @@ export const KONTAKT_ROLLEN = [
|
|||||||
'SONSTIGES',
|
'SONSTIGES',
|
||||||
] as const satisfies readonly KontaktRolle[]
|
] as const satisfies readonly KontaktRolle[]
|
||||||
|
|
||||||
|
/** Anreden fuer die Briefanrede - Reihenfolge der Auswahlliste. */
|
||||||
|
export const KONTAKT_ANREDEN = [
|
||||||
|
'OHNE',
|
||||||
|
'FRAU',
|
||||||
|
'HERR',
|
||||||
|
] as const satisfies readonly KontaktAnrede[]
|
||||||
|
|
||||||
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
|
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
|
||||||
RECHTSPFLEGER: 'Rechtspfleger',
|
RECHTSPFLEGER: 'Rechtspfleger',
|
||||||
RICHTER: 'Richter',
|
RICHTER: 'Richter',
|
||||||
@@ -177,6 +184,7 @@ export const KontaktSchema = z.object({
|
|||||||
rolle: z.enum(KONTAKT_ROLLEN, {
|
rolle: z.enum(KONTAKT_ROLLEN, {
|
||||||
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
|
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
|
||||||
}),
|
}),
|
||||||
|
anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'),
|
||||||
telefon: optionalerText(50),
|
telefon: optionalerText(50),
|
||||||
mobil: optionalerText(50),
|
mobil: optionalerText(50),
|
||||||
fax: optionalerText(50),
|
fax: optionalerText(50),
|
||||||
@@ -198,6 +206,7 @@ export function kontaktFormularLesen(formular: FormData): Record<string, unknown
|
|||||||
for (const feld of [
|
for (const feld of [
|
||||||
'name',
|
'name',
|
||||||
'organisation',
|
'organisation',
|
||||||
|
'anrede',
|
||||||
'rolle',
|
'rolle',
|
||||||
'telefon',
|
'telefon',
|
||||||
'mobil',
|
'mobil',
|
||||||
|
|||||||
+437
-437
@@ -1,437 +1,437 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
|
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eingabeprüfung für das Sozialhilfemodul.
|
* Eingabeprüfung für das Sozialhilfemodul.
|
||||||
*
|
*
|
||||||
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
|
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
|
||||||
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
|
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
|
||||||
* Nachforderung erfasst, hat keine Lust auf "invalid input".
|
* Nachforderung erfasst, hat keine Lust auf "invalid input".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const optionalerText = (max = 200) =>
|
const optionalerText = (max = 200) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(max, `Bitte höchstens ${max} Zeichen.`)
|
.max(max, `Bitte höchstens ${max} Zeichen.`)
|
||||||
.transform((wert) => (wert === '' ? null : wert))
|
.transform((wert) => (wert === '' ? null : wert))
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null)
|
.default(null)
|
||||||
|
|
||||||
const optionalesDatum = z
|
const optionalesDatum = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.transform((wert) => (wert === '' ? null : wert))
|
.transform((wert) => (wert === '' ? null : wert))
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null)
|
.default(null)
|
||||||
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
||||||
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
||||||
})
|
})
|
||||||
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
|
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
|
||||||
|
|
||||||
const pflichtDatum = z
|
const pflichtDatum = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, 'Bitte ein Datum angeben.')
|
.min(1, 'Bitte ein Datum angeben.')
|
||||||
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
|
||||||
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
|
||||||
})
|
})
|
||||||
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
|
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
|
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
|
||||||
* Angabe" und "null Euro" sind fachlich zweierlei.
|
* Angabe" und "null Euro" sind fachlich zweierlei.
|
||||||
*/
|
*/
|
||||||
const optionalerBetrag = z
|
const optionalerBetrag = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.default('')
|
.default('')
|
||||||
.transform((wert, ctx) => {
|
.transform((wert, ctx) => {
|
||||||
if (wert === '') return null
|
if (wert === '') return null
|
||||||
const cent = betragParsenWennMoeglich(wert)
|
const cent = betragParsenWennMoeglich(wert)
|
||||||
if (cent === null) {
|
if (cent === null) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
|
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
|
||||||
})
|
})
|
||||||
return z.NEVER
|
return z.NEVER
|
||||||
}
|
}
|
||||||
return cent
|
return cent
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
|
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
|
||||||
const betragOderNull = z
|
const betragOderNull = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.default('')
|
.default('')
|
||||||
.transform((wert, ctx) => {
|
.transform((wert, ctx) => {
|
||||||
if (wert === '') return 0n
|
if (wert === '') return 0n
|
||||||
const cent = betragParsenWennMoeglich(wert)
|
const cent = betragParsenWennMoeglich(wert)
|
||||||
if (cent === null) {
|
if (cent === null) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
|
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
|
||||||
})
|
})
|
||||||
return z.NEVER
|
return z.NEVER
|
||||||
}
|
}
|
||||||
if (cent < 0n) {
|
if (cent < 0n) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'In dieser Aufstellung stehen nur positive Beträge.',
|
message: 'In dieser Aufstellung stehen nur positive Beträge.',
|
||||||
})
|
})
|
||||||
return z.NEVER
|
return z.NEVER
|
||||||
}
|
}
|
||||||
return cent
|
return cent
|
||||||
})
|
})
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Antragsakte
|
// Antragsakte
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const AntragSchema = z
|
export const AntragSchema = z
|
||||||
.object({
|
.object({
|
||||||
traeger: z
|
traeger: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
|
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
|
||||||
.max(150),
|
.max(150),
|
||||||
leistungsart: z
|
leistungsart: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(2, 'Bitte die Leistungsart angeben.')
|
.min(2, 'Bitte die Leistungsart angeben.')
|
||||||
.max(200),
|
.max(200),
|
||||||
aktenzeichen: optionalerText(80),
|
aktenzeichen: optionalerText(80),
|
||||||
sachbearbeiter: optionalerText(150),
|
sachbearbeiter: optionalerText(150),
|
||||||
telefon: optionalerText(50),
|
telefon: optionalerText(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(200)
|
.max(200)
|
||||||
.transform((wert) => (wert === '' ? null : wert))
|
.transform((wert) => (wert === '' ? null : wert))
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null)
|
.default(null)
|
||||||
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
|
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
|
||||||
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
|
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
|
||||||
}),
|
}),
|
||||||
antragAm: optionalesDatum,
|
antragAm: optionalesDatum,
|
||||||
eingangBeimTraegerAm: optionalesDatum,
|
eingangBeimTraegerAm: optionalesDatum,
|
||||||
bewilligtVon: optionalesDatum,
|
bewilligtVon: optionalesDatum,
|
||||||
bewilligtBis: optionalesDatum,
|
bewilligtBis: optionalesDatum,
|
||||||
notizen: optionalerText(4000),
|
notizen: optionalerText(4000),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(daten) =>
|
(daten) =>
|
||||||
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
|
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
|
||||||
{
|
{
|
||||||
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
|
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
|
||||||
path: ['bewilligtBis'],
|
path: ['bewilligtBis'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(daten) =>
|
(daten) =>
|
||||||
!daten.antragAm ||
|
!daten.antragAm ||
|
||||||
!daten.eingangBeimTraegerAm ||
|
!daten.eingangBeimTraegerAm ||
|
||||||
daten.antragAm <= daten.eingangBeimTraegerAm,
|
daten.antragAm <= daten.eingangBeimTraegerAm,
|
||||||
{
|
{
|
||||||
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
|
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
|
||||||
path: ['eingangBeimTraegerAm'],
|
path: ['eingangBeimTraegerAm'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export type AntragEingabe = z.infer<typeof AntragSchema>
|
export type AntragEingabe = z.infer<typeof AntragSchema>
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Nachforderung
|
// Nachforderung
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const NACHFORDERUNG_STATUS_WERTE = [
|
export const NACHFORDERUNG_STATUS_WERTE = [
|
||||||
'OFFEN',
|
'OFFEN',
|
||||||
'IN_BESCHAFFUNG',
|
'IN_BESCHAFFUNG',
|
||||||
'UEBERSANDT',
|
'UEBERSANDT',
|
||||||
'ANERKANNT',
|
'ANERKANNT',
|
||||||
'NICHT_VORHANDEN',
|
'NICHT_VORHANDEN',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const NachforderungSchema = z
|
export const NachforderungSchema = z
|
||||||
.object({
|
.object({
|
||||||
bezeichnung: z
|
bezeichnung: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
|
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
|
||||||
.max(200),
|
.max(200),
|
||||||
erlaeuterung: optionalerText(2000),
|
erlaeuterung: optionalerText(2000),
|
||||||
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
|
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
|
||||||
angefordertAm: optionalesDatum,
|
angefordertAm: optionalesDatum,
|
||||||
fristBis: optionalesDatum,
|
fristBis: optionalesDatum,
|
||||||
uebersandtAm: optionalesDatum,
|
uebersandtAm: optionalesDatum,
|
||||||
begruendungNichtVorhanden: optionalerText(2000),
|
begruendungNichtVorhanden: optionalerText(2000),
|
||||||
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
|
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
|
||||||
dokumentId: optionalerText(40),
|
dokumentId: optionalerText(40),
|
||||||
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
|
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
|
||||||
fristAnlegen: z.coerce.boolean().default(false),
|
fristAnlegen: z.coerce.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(daten) =>
|
(daten) =>
|
||||||
daten.status !== 'NICHT_VORHANDEN' ||
|
daten.status !== 'NICHT_VORHANDEN' ||
|
||||||
(daten.begruendungNichtVorhanden !== null &&
|
(daten.begruendungNichtVorhanden !== null &&
|
||||||
daten.begruendungNichtVorhanden.trim().length >= 5),
|
daten.begruendungNichtVorhanden.trim().length >= 5),
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
|
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
|
||||||
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
|
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
|
||||||
path: ['begruendungNichtVorhanden'],
|
path: ['begruendungNichtVorhanden'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
|
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
|
||||||
message:
|
message:
|
||||||
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
|
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
|
||||||
'Träger später von fehlenden Unterlagen ausgeht.',
|
'Träger später von fehlenden Unterlagen ausgeht.',
|
||||||
path: ['uebersandtAm'],
|
path: ['uebersandtAm'],
|
||||||
})
|
})
|
||||||
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
|
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
|
||||||
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
|
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
|
||||||
path: ['fristBis'],
|
path: ['fristBis'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
|
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
|
||||||
|
|
||||||
export const NachforderungStatusSchema = z
|
export const NachforderungStatusSchema = z
|
||||||
.object({
|
.object({
|
||||||
nachforderungId: z.string().trim().min(1),
|
nachforderungId: z.string().trim().min(1),
|
||||||
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
|
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
|
||||||
begruendungNichtVorhanden: optionalerText(2000),
|
begruendungNichtVorhanden: optionalerText(2000),
|
||||||
uebersandtAm: optionalesDatum,
|
uebersandtAm: optionalesDatum,
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(daten) =>
|
(daten) =>
|
||||||
daten.status !== 'NICHT_VORHANDEN' ||
|
daten.status !== 'NICHT_VORHANDEN' ||
|
||||||
(daten.begruendungNichtVorhanden !== null &&
|
(daten.begruendungNichtVorhanden !== null &&
|
||||||
daten.begruendungNichtVorhanden.trim().length >= 5),
|
daten.begruendungNichtVorhanden.trim().length >= 5),
|
||||||
{
|
{
|
||||||
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
|
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
|
||||||
path: ['begruendungNichtVorhanden'],
|
path: ['begruendungNichtVorhanden'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const ChecklisteSchema = z.object({
|
export const ChecklisteSchema = z.object({
|
||||||
antragId: z.string().trim().min(1),
|
antragId: z.string().trim().min(1),
|
||||||
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
|
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
|
||||||
angefordertAm: optionalesDatum,
|
angefordertAm: optionalesDatum,
|
||||||
fristBis: optionalesDatum,
|
fristBis: optionalesDatum,
|
||||||
})
|
})
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Bescheid
|
// Bescheid
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const BESCHEID_ART_WERTE = [
|
export const BESCHEID_ART_WERTE = [
|
||||||
'BEWILLIGUNG',
|
'BEWILLIGUNG',
|
||||||
'ABLEHNUNG',
|
'ABLEHNUNG',
|
||||||
'AENDERUNG',
|
'AENDERUNG',
|
||||||
'AUFHEBUNG',
|
'AUFHEBUNG',
|
||||||
'ERSTATTUNG',
|
'ERSTATTUNG',
|
||||||
'VORSCHUSS',
|
'VORSCHUSS',
|
||||||
'WIDERSPRUCHSBESCHEID',
|
'WIDERSPRUCHSBESCHEID',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const PRUEFSTATUS_WERTE = [
|
export const PRUEFSTATUS_WERTE = [
|
||||||
'UNGEPRUEFT',
|
'UNGEPRUEFT',
|
||||||
'GEPRUEFT_OK',
|
'GEPRUEFT_OK',
|
||||||
'WIDERSPRUCH_EINGELEGT',
|
'WIDERSPRUCH_EINGELEGT',
|
||||||
'WIDERSPRUCH_ERLEDIGT',
|
'WIDERSPRUCH_ERLEDIGT',
|
||||||
'FRIST_VERSTRICHEN',
|
'FRIST_VERSTRICHEN',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const BescheidSchema = z
|
export const BescheidSchema = z
|
||||||
.object({
|
.object({
|
||||||
art: z.enum(BESCHEID_ART_WERTE),
|
art: z.enum(BESCHEID_ART_WERTE),
|
||||||
bescheidVom: pflichtDatum,
|
bescheidVom: pflichtDatum,
|
||||||
zugegangenAm: optionalesDatum,
|
zugegangenAm: optionalesDatum,
|
||||||
zeitraumVon: optionalesDatum,
|
zeitraumVon: optionalesDatum,
|
||||||
zeitraumBis: optionalesDatum,
|
zeitraumBis: optionalesDatum,
|
||||||
betrag: optionalerBetrag,
|
betrag: optionalerBetrag,
|
||||||
/**
|
/**
|
||||||
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
|
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
|
||||||
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
|
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
|
||||||
*/
|
*/
|
||||||
rechtsbehelfsfristMonate: z.coerce
|
rechtsbehelfsfristMonate: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.refine((wert) => wert === 1 || wert === 12, {
|
.refine((wert) => wert === 1 || wert === 12, {
|
||||||
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
|
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
|
||||||
})
|
})
|
||||||
.default(1),
|
.default(1),
|
||||||
widerspruchEingelegtAm: optionalesDatum,
|
widerspruchEingelegtAm: optionalesDatum,
|
||||||
notizen: optionalerText(4000),
|
notizen: optionalerText(4000),
|
||||||
dokumentId: optionalerText(40),
|
dokumentId: optionalerText(40),
|
||||||
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
|
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
|
||||||
fristAnlegen: z.coerce.boolean().default(false),
|
fristAnlegen: z.coerce.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
|
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
|
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
|
||||||
'Poststempel.',
|
'Poststempel.',
|
||||||
path: ['zugegangenAm'],
|
path: ['zugegangenAm'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
|
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
|
||||||
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
|
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
|
||||||
)
|
)
|
||||||
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
|
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
|
||||||
message:
|
message:
|
||||||
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
|
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
|
||||||
'keine Frist anlegen.',
|
'keine Frist anlegen.',
|
||||||
path: ['zugegangenAm'],
|
path: ['zugegangenAm'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export type BescheidEingabe = z.infer<typeof BescheidSchema>
|
export type BescheidEingabe = z.infer<typeof BescheidSchema>
|
||||||
|
|
||||||
export const BescheidStatusSchema = z.object({
|
export const BescheidStatusSchema = z.object({
|
||||||
bescheidId: z.string().trim().min(1),
|
bescheidId: z.string().trim().min(1),
|
||||||
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
|
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
|
||||||
widerspruchEingelegtAm: optionalesDatum,
|
widerspruchEingelegtAm: optionalesDatum,
|
||||||
})
|
})
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Vermögensbewertung
|
// Vermögensbewertung
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
|
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
|
||||||
|
|
||||||
export const BewertungSchema = z
|
export const BewertungSchema = z
|
||||||
.object({
|
.object({
|
||||||
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
|
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
|
||||||
id: z.string().trim().min(1),
|
id: z.string().trim().min(1),
|
||||||
bewertung: z.enum(BEWERTUNG_WERTE),
|
bewertung: z.enum(BEWERTUNG_WERTE),
|
||||||
begruendung: optionalerText(6000),
|
begruendung: optionalerText(6000),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(daten) =>
|
(daten) =>
|
||||||
daten.bewertung === 'UNGEPRUEFT' ||
|
daten.bewertung === 'UNGEPRUEFT' ||
|
||||||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
|
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
|
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
|
||||||
'Übernimm einen der Textbausteine und passe ihn an.',
|
'Übernimm einen der Textbausteine und passe ihn an.',
|
||||||
path: ['begruendung'],
|
path: ['begruendung'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Heimkosten
|
// Heimkosten
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const HeimkostenSchema = z.object({
|
export const HeimkostenSchema = z.object({
|
||||||
jahr: z.coerce
|
jahr: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
|
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
|
||||||
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
|
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
|
||||||
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
|
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
|
||||||
|
|
||||||
pflegeEigenanteil: betragOderNull,
|
pflegeEigenanteil: betragOderNull,
|
||||||
unterkunftVerpflegung: betragOderNull,
|
unterkunftVerpflegung: betragOderNull,
|
||||||
investitionskosten: betragOderNull,
|
investitionskosten: betragOderNull,
|
||||||
ausbildungsumlage: betragOderNull,
|
ausbildungsumlage: betragOderNull,
|
||||||
zusatzleistungen: betragOderNull,
|
zusatzleistungen: betragOderNull,
|
||||||
|
|
||||||
pflegekasse: betragOderNull,
|
pflegekasse: betragOderNull,
|
||||||
leistungszuschlagProzent: z.coerce
|
leistungszuschlagProzent: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
|
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
|
||||||
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
|
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
|
||||||
.default(0),
|
.default(0),
|
||||||
leistungszuschlag: betragOderNull,
|
leistungszuschlag: betragOderNull,
|
||||||
rente: betragOderNull,
|
rente: betragOderNull,
|
||||||
sonstigeEinkuenfte: betragOderNull,
|
sonstigeEinkuenfte: betragOderNull,
|
||||||
sozialhilfe: betragOderNull,
|
sozialhilfe: betragOderNull,
|
||||||
eigenmittel: betragOderNull,
|
eigenmittel: betragOderNull,
|
||||||
|
|
||||||
barbetrag: betragOderNull,
|
barbetrag: betragOderNull,
|
||||||
bekleidungspauschale: betragOderNull,
|
bekleidungspauschale: betragOderNull,
|
||||||
|
|
||||||
abwesenheitstage: z.coerce
|
abwesenheitstage: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(0, 'Abwesenheitstage können nicht negativ sein.')
|
.min(0, 'Abwesenheitstage können nicht negativ sein.')
|
||||||
.max(31, 'Ein Monat hat höchstens 31 Tage.')
|
.max(31, 'Ein Monat hat höchstens 31 Tage.')
|
||||||
.default(0),
|
.default(0),
|
||||||
|
|
||||||
bemerkung: optionalerText(2000),
|
bemerkung: optionalerText(2000),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
|
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Weitere Leistungen
|
// Weitere Leistungen
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LEISTUNG_STATUS_WERTE = [
|
export const LEISTUNG_STATUS_WERTE = [
|
||||||
'NICHT_GEPRUEFT',
|
'NICHT_GEPRUEFT',
|
||||||
'PRUEFUNG_LAEUFT',
|
'PRUEFUNG_LAEUFT',
|
||||||
'BEANTRAGT',
|
'BEANTRAGT',
|
||||||
'BEWILLIGT',
|
'BEWILLIGT',
|
||||||
'ABGELEHNT',
|
'ABGELEHNT',
|
||||||
'LAEUFT',
|
'LAEUFT',
|
||||||
'NICHT_ZUTREFFEND',
|
'NICHT_ZUTREFFEND',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const LeistungSchema = z
|
export const LeistungSchema = z
|
||||||
.object({
|
.object({
|
||||||
leistungId: optionalerText(40),
|
leistungId: optionalerText(40),
|
||||||
katalogSchluessel: optionalerText(80),
|
katalogSchluessel: optionalerText(80),
|
||||||
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
|
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
|
||||||
traeger: optionalerText(200),
|
traeger: optionalerText(200),
|
||||||
rechtsgrundlage: optionalerText(200),
|
rechtsgrundlage: optionalerText(200),
|
||||||
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
|
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
|
||||||
beantragtAm: optionalesDatum,
|
beantragtAm: optionalesDatum,
|
||||||
bewilligtAm: optionalesDatum,
|
bewilligtAm: optionalesDatum,
|
||||||
gueltigBis: optionalesDatum,
|
gueltigBis: optionalesDatum,
|
||||||
betrag: optionalerBetrag,
|
betrag: optionalerBetrag,
|
||||||
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
|
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
|
||||||
notizen: optionalerText(2000),
|
notizen: optionalerText(2000),
|
||||||
})
|
})
|
||||||
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
|
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
|
||||||
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
|
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
|
||||||
path: ['bewilligtAm'],
|
path: ['bewilligtAm'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const KatalogUebernehmenSchema = z.object({
|
export const KatalogUebernehmenSchema = z.object({
|
||||||
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
|
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Formularhilfen
|
// Formularhilfen
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Kontrollkästchen liefern "on" oder gar nichts. */
|
/** Kontrollkästchen liefern "on" oder gar nichts. */
|
||||||
export function kaestchen(formular: FormData, name: string): boolean {
|
export function kaestchen(formular: FormData, name: string): boolean {
|
||||||
const wert = formular.get(name)
|
const wert = formular.get(name)
|
||||||
return wert === 'on' || wert === 'true' || wert === '1'
|
return wert === 'on' || wert === 'true' || wert === '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
|
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
|
||||||
export function formularLesen(formular: FormData): Record<string, unknown> {
|
export function formularLesen(formular: FormData): Record<string, unknown> {
|
||||||
const roh: Record<string, unknown> = {}
|
const roh: Record<string, unknown> = {}
|
||||||
for (const [name, wert] of formular.entries()) {
|
for (const [name, wert] of formular.entries()) {
|
||||||
if (typeof wert === 'string') roh[name] = wert
|
if (typeof wert === 'string') roh[name] = wert
|
||||||
}
|
}
|
||||||
return roh
|
return roh
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
|
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
|
||||||
*/
|
*/
|
||||||
export function feldfehler(fehler: z.ZodError): Record<string, string> {
|
export function feldfehler(fehler: z.ZodError): Record<string, string> {
|
||||||
const flach = fehler.flatten().fieldErrors
|
const flach = fehler.flatten().fieldErrors
|
||||||
const ergebnis: Record<string, string> = {}
|
const ergebnis: Record<string, string> = {}
|
||||||
for (const [feld, meldungen] of Object.entries(flach)) {
|
for (const [feld, meldungen] of Object.entries(flach)) {
|
||||||
const erste = meldungen?.[0]
|
const erste = meldungen?.[0]
|
||||||
if (erste) ergebnis[feld] = erste
|
if (erste) ergebnis[feld] = erste
|
||||||
}
|
}
|
||||||
return ergebnis
|
return ergebnis
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-171
@@ -1,171 +1,171 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
optionalerBetrag,
|
optionalerBetrag,
|
||||||
optionalerText,
|
optionalerText,
|
||||||
optionaleZahl,
|
optionaleZahl,
|
||||||
optionalesDatum,
|
optionalesDatum,
|
||||||
pflichtBetrag,
|
pflichtBetrag,
|
||||||
pflichtDatum,
|
pflichtDatum,
|
||||||
pflichtText,
|
pflichtText,
|
||||||
} from '@/lib/validierung/substanz'
|
} from '@/lib/validierung/substanz'
|
||||||
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
|
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eingabepruefung fuer Versicherungen und Vertraege.
|
* Eingabepruefung fuer Versicherungen und Vertraege.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const VERTRAG_ARTEN = [
|
export const VERTRAG_ARTEN = [
|
||||||
'LEBENSVERSICHERUNG',
|
'LEBENSVERSICHERUNG',
|
||||||
'STERBEGELDVERSICHERUNG',
|
'STERBEGELDVERSICHERUNG',
|
||||||
'BESTATTUNGSVORSORGE',
|
'BESTATTUNGSVORSORGE',
|
||||||
'RENTENVERSICHERUNG_PRIVAT',
|
'RENTENVERSICHERUNG_PRIVAT',
|
||||||
'UNFALLVERSICHERUNG',
|
'UNFALLVERSICHERUNG',
|
||||||
'HAFTPFLICHT',
|
'HAFTPFLICHT',
|
||||||
'HAUSRAT',
|
'HAUSRAT',
|
||||||
'WOHNGEBAEUDE',
|
'WOHNGEBAEUDE',
|
||||||
'KRANKENZUSATZ',
|
'KRANKENZUSATZ',
|
||||||
'PFLEGEZUSATZ',
|
'PFLEGEZUSATZ',
|
||||||
'RECHTSSCHUTZ',
|
'RECHTSSCHUTZ',
|
||||||
'STERBEKASSE',
|
'STERBEKASSE',
|
||||||
'STROM',
|
'STROM',
|
||||||
'GAS',
|
'GAS',
|
||||||
'WASSER',
|
'WASSER',
|
||||||
'TELEFON_INTERNET',
|
'TELEFON_INTERNET',
|
||||||
'RUNDFUNKBEITRAG',
|
'RUNDFUNKBEITRAG',
|
||||||
'MITGLIEDSCHAFT',
|
'MITGLIEDSCHAFT',
|
||||||
'ABONNEMENT',
|
'ABONNEMENT',
|
||||||
'SONSTIGES',
|
'SONSTIGES',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const VERTRAG_STATUS = [
|
export const VERTRAG_STATUS = [
|
||||||
'AKTIV',
|
'AKTIV',
|
||||||
'BEITRAGSFREI',
|
'BEITRAGSFREI',
|
||||||
'RUHEND',
|
'RUHEND',
|
||||||
'GEKUENDIGT',
|
'GEKUENDIGT',
|
||||||
'ABGETRETEN',
|
'ABGETRETEN',
|
||||||
'VERWERTET',
|
'VERWERTET',
|
||||||
'ABGELAUFEN',
|
'ABGELAUFEN',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const ZAHL_RHYTHMEN = [
|
export const ZAHL_RHYTHMEN = [
|
||||||
'MONATLICH',
|
'MONATLICH',
|
||||||
'VIERTELJAEHRLICH',
|
'VIERTELJAEHRLICH',
|
||||||
'HALBJAEHRLICH',
|
'HALBJAEHRLICH',
|
||||||
'JAEHRLICH',
|
'JAEHRLICH',
|
||||||
'EINMALIG',
|
'EINMALIG',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
|
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
|
||||||
|
|
||||||
export const VertragSchema = z
|
export const VertragSchema = z
|
||||||
.object({
|
.object({
|
||||||
bezeichnung: pflichtText(
|
bezeichnung: pflichtText(
|
||||||
150,
|
150,
|
||||||
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
|
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
|
||||||
),
|
),
|
||||||
gesellschaft: optionalerText(150),
|
gesellschaft: optionalerText(150),
|
||||||
art: z.enum(VERTRAG_ARTEN),
|
art: z.enum(VERTRAG_ARTEN),
|
||||||
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
|
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
|
||||||
|
|
||||||
/** Wird verschlüsselt gespeichert. */
|
/** Wird verschlüsselt gespeichert. */
|
||||||
vertragsnummer: optionalerText(80),
|
vertragsnummer: optionalerText(80),
|
||||||
|
|
||||||
beginnAm: optionalesDatum,
|
beginnAm: optionalesDatum,
|
||||||
endeAm: optionalesDatum,
|
endeAm: optionalesDatum,
|
||||||
kuendigungsfristMonate: optionaleZahl(
|
kuendigungsfristMonate: optionaleZahl(
|
||||||
0,
|
0,
|
||||||
120,
|
120,
|
||||||
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
|
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
|
||||||
),
|
),
|
||||||
kuendigungZum: optionalesDatum,
|
kuendigungZum: optionalesDatum,
|
||||||
gekuendigtAm: optionalesDatum,
|
gekuendigtAm: optionalesDatum,
|
||||||
|
|
||||||
beitrag: optionalerBetrag,
|
beitrag: optionalerBetrag,
|
||||||
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
|
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
|
||||||
|
|
||||||
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
|
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
|
||||||
bewertungBegruendung: optionalerText(4000),
|
bewertungBegruendung: optionalerText(4000),
|
||||||
|
|
||||||
notizen: optionalerText(4000),
|
notizen: optionalerText(4000),
|
||||||
})
|
})
|
||||||
.superRefine((daten, kontext) => {
|
.superRefine((daten, kontext) => {
|
||||||
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
|
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
|
||||||
kontext.addIssue({
|
kontext.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['endeAm'],
|
path: ['endeAm'],
|
||||||
message: 'Das Vertragsende liegt vor dem Beginn.',
|
message: 'Das Vertragsende liegt vor dem Beginn.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (daten.beitrag !== null && !daten.rhythmus) {
|
if (daten.beitrag !== null && !daten.rhythmus) {
|
||||||
kontext.addIssue({
|
kontext.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['rhythmus'],
|
path: ['rhythmus'],
|
||||||
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
|
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
|
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
|
||||||
kontext.addIssue({
|
kontext.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['bewertungBegruendung'],
|
path: ['bewertungBegruendung'],
|
||||||
message:
|
message:
|
||||||
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
|
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
|
||||||
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
|
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
|
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
|
||||||
kontext.addIssue({
|
kontext.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['gekuendigtAm'],
|
path: ['gekuendigtAm'],
|
||||||
message: 'Bitte das Datum der Kündigung angeben.',
|
message: 'Bitte das Datum der Kündigung angeben.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export type VertragEingabe = z.infer<typeof VertragSchema>
|
export type VertragEingabe = z.infer<typeof VertragSchema>
|
||||||
|
|
||||||
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
|
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
|
||||||
export const RueckkaufswertSchema = z.object({
|
export const RueckkaufswertSchema = z.object({
|
||||||
stichtag: pflichtDatum,
|
stichtag: pflichtDatum,
|
||||||
wert: pflichtBetrag,
|
wert: pflichtBetrag,
|
||||||
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
|
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
|
||||||
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
|
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
|
||||||
}),
|
}),
|
||||||
bemerkung: optionalerText(1000),
|
bemerkung: optionalerText(1000),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
|
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
|
||||||
|
|
||||||
export const RueckkaufswertAnfrageSchema = z.object({
|
export const RueckkaufswertAnfrageSchema = z.object({
|
||||||
stichtag: pflichtDatum,
|
stichtag: pflichtDatum,
|
||||||
empfaengerAnschrift: optionalerText(500),
|
empfaengerAnschrift: optionalerText(500),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
|
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
|
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
|
||||||
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
|
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
|
||||||
* gekündigt hat.
|
* gekündigt hat.
|
||||||
*/
|
*/
|
||||||
export const KuendigungVermerkenSchema = z
|
export const KuendigungVermerkenSchema = z
|
||||||
.object({
|
.object({
|
||||||
gekuendigtAm: pflichtDatum,
|
gekuendigtAm: pflichtDatum,
|
||||||
kuendigungZum: optionalesDatum,
|
kuendigungZum: optionalesDatum,
|
||||||
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
|
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
|
||||||
nachweisVorhanden: z.boolean().default(false),
|
nachweisVorhanden: z.boolean().default(false),
|
||||||
genehmigungGeklaert: z.boolean().default(false),
|
genehmigungGeklaert: z.boolean().default(false),
|
||||||
bemerkung: optionalerText(2000),
|
bemerkung: optionalerText(2000),
|
||||||
})
|
})
|
||||||
.superRefine((daten, kontext) => {
|
.superRefine((daten, kontext) => {
|
||||||
if (!daten.nachweisVorhanden) {
|
if (!daten.nachweisVorhanden) {
|
||||||
kontext.addIssue({
|
kontext.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['nachweisVorhanden'],
|
path: ['nachweisVorhanden'],
|
||||||
message:
|
message:
|
||||||
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
|
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
|
||||||
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
|
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
|
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const optionalerText = (max = 200) =>
|
|||||||
.transform((wert) => (wert === '' ? null : wert))
|
.transform((wert) => (wert === '' ? null : wert))
|
||||||
.nullable()
|
.nullable()
|
||||||
|
|
||||||
|
/** Ein Verweis auf einen anderen Datensatz - leer heisst "keiner". */
|
||||||
|
const optionaleId = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((wert) => (wert === '' ? null : wert))
|
||||||
|
.nullable()
|
||||||
|
.default(null)
|
||||||
|
|
||||||
const optionalesDatum = z
|
const optionalesDatum = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -90,6 +98,7 @@ export const VorgangSchema = z.object({
|
|||||||
beschreibung: optionalerText(4000),
|
beschreibung: optionalerText(4000),
|
||||||
gegenstelle: optionalerText(150),
|
gegenstelle: optionalerText(150),
|
||||||
aktenzeichen: optionalerText(80),
|
aktenzeichen: optionalerText(80),
|
||||||
|
ansprechpartnerId: optionaleId,
|
||||||
verantwortlich: optionalerText(120),
|
verantwortlich: optionalerText(120),
|
||||||
naechsterSchritt: optionalerText(300),
|
naechsterSchritt: optionalerText(300),
|
||||||
faelligAm: optionalesDatum,
|
faelligAm: optionalesDatum,
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Muss zur "version" in der package.json passen. */
|
/** Muss zur "version" in der package.json passen. */
|
||||||
export const VERSION = '1.3.0'
|
export const VERSION = '1.10.2'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import type { Briefkopf } from '@/lib/vertraege/anschreiben'
|
|||||||
/**
|
/**
|
||||||
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
|
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
|
||||||
*
|
*
|
||||||
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer
|
* Die Anschrift des Betreuers haengt am Benutzer, nicht an der betreuten
|
||||||
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text
|
* Person: Sie gehoert zu mir und gilt fuer alle Akten. Ist sie nicht
|
||||||
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert,
|
* hinterlegt, bleiben die Zeilen weg - ein Absenderblock mit leeren
|
||||||
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin.
|
* 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 = {
|
export type BriefkopfQuelle = {
|
||||||
betreute: {
|
betreute: {
|
||||||
vorname: string
|
vorname: string
|
||||||
@@ -22,7 +31,7 @@ export type BriefkopfQuelle = {
|
|||||||
aktenzeichenGericht: string | null
|
aktenzeichenGericht: string | null
|
||||||
bestellungAm: Date | null
|
bestellungAm: Date | null
|
||||||
}
|
}
|
||||||
benutzer: { name: string }
|
benutzer: Absender
|
||||||
}
|
}
|
||||||
|
|
||||||
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
|
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
|
||||||
@@ -37,11 +46,11 @@ export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
betreuerName: quelle.benutzer.name,
|
betreuerName: quelle.benutzer.name,
|
||||||
betreuerStrasse: null,
|
betreuerStrasse: quelle.benutzer.strasse ?? null,
|
||||||
betreuerPlz: null,
|
betreuerPlz: quelle.benutzer.plz ?? null,
|
||||||
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des
|
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der
|
||||||
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer.
|
// des Heims.
|
||||||
betreuerOrt: null,
|
betreuerOrt: quelle.benutzer.ort ?? null,
|
||||||
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
|
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
|
||||||
betreuteGeburtsdatum: b.geburtsdatum,
|
betreuteGeburtsdatum: b.geburtsdatum,
|
||||||
betreuteAnschrift: anschrift || null,
|
betreuteAnschrift: anschrift || null,
|
||||||
|
|||||||
+227
-227
@@ -1,227 +1,227 @@
|
|||||||
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
|
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kuendigungsfrist-Rechner.
|
* Kuendigungsfrist-Rechner.
|
||||||
*
|
*
|
||||||
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
|
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
|
||||||
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
|
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
|
||||||
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
|
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
|
||||||
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
|
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
|
||||||
* verpasst und zahlt ein weiteres Jahr.
|
* verpasst und zahlt ein weiteres Jahr.
|
||||||
*
|
*
|
||||||
* Deshalb rechnet diese Datei drei Daten aus:
|
* Deshalb rechnet diese Datei drei Daten aus:
|
||||||
*
|
*
|
||||||
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
|
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
|
||||||
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
|
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
|
||||||
* 3. den spaetesten Absendetag mit Postlaufpuffer.
|
* 3. den spaetesten Absendetag mit Postlaufpuffer.
|
||||||
*
|
*
|
||||||
* Reine Logik, keine Datenbank.
|
* Reine Logik, keine Datenbank.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
|
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
|
||||||
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
|
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
|
||||||
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
|
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
|
||||||
* Zugang und ist am selben Tag da.
|
* Zugang und ist am selben Tag da.
|
||||||
*/
|
*/
|
||||||
export const POSTLAUF_PUFFER_TAGE = 3
|
export const POSTLAUF_PUFFER_TAGE = 3
|
||||||
|
|
||||||
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
|
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
|
||||||
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
|
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
|
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
|
||||||
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
|
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
|
||||||
*/
|
*/
|
||||||
export const STANDARD_LAUFZEIT_JAHRE = 1
|
export const STANDARD_LAUFZEIT_JAHRE = 1
|
||||||
|
|
||||||
export type KuendigungEingabe = {
|
export type KuendigungEingabe = {
|
||||||
/** Laufzeitbeginn laut Vertrag. */
|
/** Laufzeitbeginn laut Vertrag. */
|
||||||
beginnAm: Date | null
|
beginnAm: Date | null
|
||||||
/** Fest vereinbartes Vertragsende, falls bekannt. */
|
/** Fest vereinbartes Vertragsende, falls bekannt. */
|
||||||
endeAm?: Date | null
|
endeAm?: Date | null
|
||||||
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
|
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
|
||||||
laufzeitJahre?: number | null
|
laufzeitJahre?: number | null
|
||||||
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
|
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
|
||||||
kuendigungsfristMonate: number | null
|
kuendigungsfristMonate: number | null
|
||||||
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
|
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
|
||||||
verlaengertSichAutomatisch?: boolean
|
verlaengertSichAutomatisch?: boolean
|
||||||
/** Bezugstag; im Test gesetzt, sonst heute. */
|
/** Bezugstag; im Test gesetzt, sonst heute. */
|
||||||
heute?: Date
|
heute?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Kuendigungsplan = {
|
export type Kuendigungsplan = {
|
||||||
/** Laesst sich ueberhaupt etwas berechnen? */
|
/** Laesst sich ueberhaupt etwas berechnen? */
|
||||||
berechenbar: boolean
|
berechenbar: boolean
|
||||||
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
|
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
|
||||||
naechsterTermin: Date | null
|
naechsterTermin: Date | null
|
||||||
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
|
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
|
||||||
zugangSpaetestensAm: Date | null
|
zugangSpaetestensAm: Date | null
|
||||||
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
|
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
|
||||||
spaetesterAbsendetag: Date | null
|
spaetesterAbsendetag: Date | null
|
||||||
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
|
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
|
||||||
tageBisAbsendetag: number | null
|
tageBisAbsendetag: number | null
|
||||||
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
|
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
|
||||||
vorwarnung: boolean
|
vorwarnung: boolean
|
||||||
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
|
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
|
||||||
verpasst: boolean
|
verpasst: boolean
|
||||||
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
|
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
|
||||||
hinweise: string[]
|
hinweise: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEERER_PLAN: Kuendigungsplan = {
|
const LEERER_PLAN: Kuendigungsplan = {
|
||||||
berechenbar: false,
|
berechenbar: false,
|
||||||
naechsterTermin: null,
|
naechsterTermin: null,
|
||||||
zugangSpaetestensAm: null,
|
zugangSpaetestensAm: null,
|
||||||
spaetesterAbsendetag: null,
|
spaetesterAbsendetag: null,
|
||||||
tageBisAbsendetag: null,
|
tageBisAbsendetag: null,
|
||||||
vorwarnung: false,
|
vorwarnung: false,
|
||||||
verpasst: false,
|
verpasst: false,
|
||||||
hinweise: [],
|
hinweise: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Berechnet den naechstmoeglichen Kuendigungstermin.
|
* Berechnet den naechstmoeglichen Kuendigungstermin.
|
||||||
*
|
*
|
||||||
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
|
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
|
||||||
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
|
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
|
||||||
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
|
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
|
||||||
* rausgeht, ist der naechstmoegliche.
|
* rausgeht, ist der naechstmoegliche.
|
||||||
*/
|
*/
|
||||||
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
|
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
|
||||||
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
|
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
|
||||||
const hinweise: string[] = []
|
const hinweise: string[] = []
|
||||||
|
|
||||||
const frist = eingabe.kuendigungsfristMonate
|
const frist = eingabe.kuendigungsfristMonate
|
||||||
if (frist === null || frist === undefined) {
|
if (frist === null || frist === undefined) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
|
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
|
||||||
'bitte im Vertrag nachsehen und nachtragen.',
|
'bitte im Vertrag nachsehen und nachtragen.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const fristMonate = frist ?? 0
|
const fristMonate = frist ?? 0
|
||||||
|
|
||||||
const laufzeit =
|
const laufzeit =
|
||||||
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
|
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
|
||||||
? eingabe.laufzeitJahre
|
? eingabe.laufzeitJahre
|
||||||
: STANDARD_LAUFZEIT_JAHRE
|
: STANDARD_LAUFZEIT_JAHRE
|
||||||
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
|
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
|
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
|
||||||
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
|
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
|
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
|
||||||
|
|
||||||
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
|
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
|
||||||
let termin: Date | null = null
|
let termin: Date | null = null
|
||||||
if (eingabe.endeAm) {
|
if (eingabe.endeAm) {
|
||||||
termin = tagesdatum(eingabe.endeAm)
|
termin = tagesdatum(eingabe.endeAm)
|
||||||
} else if (eingabe.beginnAm) {
|
} else if (eingabe.beginnAm) {
|
||||||
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
|
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!termin) {
|
if (!termin) {
|
||||||
return {
|
return {
|
||||||
...LEERER_PLAN,
|
...LEERER_PLAN,
|
||||||
hinweise: [
|
hinweise: [
|
||||||
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
|
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
|
||||||
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
|
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
|
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
|
||||||
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
|
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
|
||||||
let absendetag = absendetagZu(termin, fristMonate)
|
let absendetag = absendetagZu(termin, fristMonate)
|
||||||
let durchlaeufe = 0
|
let durchlaeufe = 0
|
||||||
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
|
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
|
||||||
termin = plusJahre(termin, laufzeit)
|
termin = plusJahre(termin, laufzeit)
|
||||||
absendetag = absendetagZu(termin, fristMonate)
|
absendetag = absendetagZu(termin, fristMonate)
|
||||||
durchlaeufe += 1
|
durchlaeufe += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const zugang = plusMonate(termin, -fristMonate)
|
const zugang = plusMonate(termin, -fristMonate)
|
||||||
const tageBis = tageZwischen(heute, absendetag)
|
const tageBis = tageZwischen(heute, absendetag)
|
||||||
const verpasst = absendetag.getTime() < heute.getTime()
|
const verpasst = absendetag.getTime() < heute.getTime()
|
||||||
|
|
||||||
if (verpasst) {
|
if (verpasst) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
|
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
|
||||||
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
|
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
|
||||||
`Termin ist nicht mehr möglich.`,
|
`Termin ist nicht mehr möglich.`,
|
||||||
)
|
)
|
||||||
} else if (durchlaeufe > 0) {
|
} else if (durchlaeufe > 0) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
|
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
|
||||||
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
|
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
|
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
|
||||||
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
|
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
|
||||||
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
|
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
|
||||||
`ein Fax mit Sendeprotokoll.`,
|
`ein Fax mit Sendeprotokoll.`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
berechenbar: true,
|
berechenbar: true,
|
||||||
naechsterTermin: termin,
|
naechsterTermin: termin,
|
||||||
zugangSpaetestensAm: zugang,
|
zugangSpaetestensAm: zugang,
|
||||||
spaetesterAbsendetag: absendetag,
|
spaetesterAbsendetag: absendetag,
|
||||||
tageBisAbsendetag: tageBis,
|
tageBisAbsendetag: tageBis,
|
||||||
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
|
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
|
||||||
verpasst,
|
verpasst,
|
||||||
hinweise,
|
hinweise,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
|
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
|
||||||
export function absendetagZu(termin: Date, fristMonate: number): Date {
|
export function absendetagZu(termin: Date, fristMonate: number): Date {
|
||||||
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
|
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
|
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
|
||||||
* Farbe ist nie die einzige Information - der Text steht daneben.
|
* Farbe ist nie die einzige Information - der Text steht daneben.
|
||||||
*/
|
*/
|
||||||
export type Kuendigungsampel = {
|
export type Kuendigungsampel = {
|
||||||
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
|
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
|
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
|
||||||
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
|
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
|
||||||
if (plan.verpasst) {
|
if (plan.verpasst) {
|
||||||
return { variante: 'rot', text: 'Absendetag verstrichen' }
|
return { variante: 'rot', text: 'Absendetag verstrichen' }
|
||||||
}
|
}
|
||||||
const tage = plan.tageBisAbsendetag
|
const tage = plan.tageBisAbsendetag
|
||||||
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
|
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
|
||||||
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
|
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
|
||||||
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
|
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
|
||||||
}
|
}
|
||||||
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
|
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
|
||||||
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
|
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
|
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
|
||||||
*
|
*
|
||||||
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
|
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
|
||||||
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
|
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
|
||||||
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
|
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
|
||||||
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
|
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
|
||||||
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
|
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
|
||||||
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
|
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
|
||||||
* Spanne unter einem halben Jahr, bleibt es beim Standard.
|
* Spanne unter einem halben Jahr, bleibt es beim Standard.
|
||||||
*/
|
*/
|
||||||
export function laufzeitAusVertrag(
|
export function laufzeitAusVertrag(
|
||||||
beginnAm: Date | null,
|
beginnAm: Date | null,
|
||||||
endeAm: Date | null,
|
endeAm: Date | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!beginnAm || !endeAm) return null
|
if (!beginnAm || !endeAm) return null
|
||||||
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
|
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
|
||||||
return jahre >= 1 ? jahre : null
|
return jahre >= 1 ? jahre : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,3 +114,31 @@ export function anfrageStatus(
|
|||||||
tageSeitAnfrage: tage,
|
tageSeitAnfrage: tage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rueckkaufswert-Historie eines Vertrags, juengster Wert zuerst.
|
||||||
|
*
|
||||||
|
* Der Wert lebt nicht am Vertrag, sondern als Wertstand am
|
||||||
|
* Vermoegensposten - genau derselbe, aus dem das Vermoegensverzeichnis
|
||||||
|
* rechnet. Es gibt also keine zweite Zahl, die abweichen koennte, und
|
||||||
|
* der Vertrag zeigt nur an, was ohnehin schon in der Akte steht.
|
||||||
|
*
|
||||||
|
* Verknuepft wird ueber die Vertrags-Id, nicht ueber die Art des Postens.
|
||||||
|
* Wer einen Posten von Hand als "Sonstiges" anlegt und mit dem Vertrag
|
||||||
|
* verknuepft, meint denselben Vertrag - er darf dadurch nicht eine
|
||||||
|
* zweite, unsichtbare Wahrheit bekommen.
|
||||||
|
*/
|
||||||
|
export function wertHistorie<T extends { stichtag: Date }>(
|
||||||
|
posten: { wertstaende: T[] }[],
|
||||||
|
): T[] {
|
||||||
|
return posten
|
||||||
|
.flatMap((p) => p.wertstaende)
|
||||||
|
.sort((a, b) => b.stichtag.getTime() - a.stichtag.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der zuletzt mitgeteilte Wert, oder null, wenn noch keiner vorliegt. */
|
||||||
|
export function juengsterWert<T extends { stichtag: Date }>(
|
||||||
|
posten: { wertstaende: T[] }[],
|
||||||
|
): T | null {
|
||||||
|
return wertHistorie(posten)[0] ?? null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { VertragArt, VertragStatus } from '@prisma/client'
|
||||||
|
import { kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
|
||||||
|
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert'
|
||||||
|
import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Serienabfrage der Rueckkaufswerte - Auswahllogik.
|
||||||
|
*
|
||||||
|
* Rein und ohne Datenbank: Welche Vertraege kommen in Frage, welche sind
|
||||||
|
* vorausgewaehlt, welche gesperrt? Das sind die Entscheidungen, die
|
||||||
|
* hinterher niemand mehr nachvollziehen kann, wenn sie in einer
|
||||||
|
* Datenbankabfrage stecken. Hier stehen sie einzeln und mit Begruendung.
|
||||||
|
*
|
||||||
|
* Der Anlass: Vor dem Vermoegensverzeichnis und vor jedem Jahresbericht
|
||||||
|
* braucht es von jeder Lebens- und Rentenversicherung den Rueckkaufswert
|
||||||
|
* zum Stichtag. Das sind jedes Mal dieselben fuenf bis zehn Briefe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SerienKandidat = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string
|
||||||
|
gesellschaft: string | null
|
||||||
|
art: VertragArt
|
||||||
|
status: VertragStatus
|
||||||
|
rueckkaufswertAngefragtAm: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KandidatBewertung = {
|
||||||
|
vertrag: SerienKandidat
|
||||||
|
/** Kommt der Vertrag ueberhaupt in Frage? */
|
||||||
|
moeglich: boolean
|
||||||
|
/** Warum nicht? Steht in der Liste, statt den Vertrag stumm auszublenden. */
|
||||||
|
hindernis: string | null
|
||||||
|
/** Vorausgewaehlt? Nur moegliche Vertraege ohne frische Anfrage. */
|
||||||
|
vorausgewaehlt: boolean
|
||||||
|
/** Tage seit der letzten Anfrage, falls eine lief. */
|
||||||
|
tageSeitAnfrage: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperrfrist: Wer in den letzten 28 Tagen schon gefragt hat, fragt nicht
|
||||||
|
* gleich noch einmal - die Antwort ist schlicht noch unterwegs. Dieselbe
|
||||||
|
* Zahl wie die Wiedervorlage; ein Test haelt beide zusammen.
|
||||||
|
*/
|
||||||
|
export const SPERRFRIST_TAGE = RUECKKAUFSWERT_WIEDERVORLAGE_TAGE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewertet einen Vertrag fuer die Serienabfrage.
|
||||||
|
*
|
||||||
|
* Grundsatz: Nicht stumm ausblenden, sondern begruenden. Ein Vertrag, der
|
||||||
|
* fehlt, wirft die Frage auf "und wo ist der?"; ein Vertrag mit dem
|
||||||
|
* Vermerk "gekuendigt" beantwortet sie gleich mit.
|
||||||
|
*/
|
||||||
|
export function bewerten(vertrag: SerienKandidat, heute: Date = heuteAlsTag()): KandidatBewertung {
|
||||||
|
const tageSeitAnfrage = vertrag.rueckkaufswertAngefragtAm
|
||||||
|
? tageZwischen(vertrag.rueckkaufswertAngefragtAm, heute)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!kannRueckkaufswertHaben(vertrag.art)) {
|
||||||
|
return {
|
||||||
|
vertrag,
|
||||||
|
moeglich: false,
|
||||||
|
hindernis: 'Diese Vertragsart bildet keinen Rückkaufswert.',
|
||||||
|
vorausgewaehlt: false,
|
||||||
|
tageSeitAnfrage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwertet oder abgelaufen: Da gibt es nichts mehr anzufragen. GEKUENDIGT
|
||||||
|
// bleibt dagegen moeglich - bis zum Wirksamwerden der Kuendigung besteht
|
||||||
|
// der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.
|
||||||
|
if (vertrag.status === 'VERWERTET' || vertrag.status === 'ABGELAUFEN') {
|
||||||
|
return {
|
||||||
|
vertrag,
|
||||||
|
moeglich: false,
|
||||||
|
hindernis:
|
||||||
|
vertrag.status === 'VERWERTET'
|
||||||
|
? 'Der Vertrag ist bereits verwertet.'
|
||||||
|
: 'Der Vertrag ist abgelaufen.',
|
||||||
|
vorausgewaehlt: false,
|
||||||
|
tageSeitAnfrage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vertrag.gesellschaft?.trim()) {
|
||||||
|
return {
|
||||||
|
vertrag,
|
||||||
|
moeglich: false,
|
||||||
|
hindernis: 'Ohne Gesellschaft fehlt der Empfänger — bitte am Vertrag nachtragen.',
|
||||||
|
vorausgewaehlt: false,
|
||||||
|
tageSeitAnfrage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frisch angefragt: moeglich, aber nicht vorausgewaehlt. Wer trotzdem
|
||||||
|
// will (etwa weil der Brief verloren ging), waehlt von Hand an.
|
||||||
|
if (tageSeitAnfrage !== null && tageSeitAnfrage < SPERRFRIST_TAGE) {
|
||||||
|
return {
|
||||||
|
vertrag,
|
||||||
|
moeglich: true,
|
||||||
|
hindernis: `Vor ${tageSeitAnfrage} Tag${tageSeitAnfrage === 1 ? '' : 'en'} bereits angefragt — die Antwort ist vermutlich noch unterwegs.`,
|
||||||
|
vorausgewaehlt: false,
|
||||||
|
tageSeitAnfrage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vertrag, moeglich: true, hindernis: null, vorausgewaehlt: true, tageSeitAnfrage }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kandidatenBewerten(
|
||||||
|
vertraege: SerienKandidat[],
|
||||||
|
heute: Date = heuteAlsTag(),
|
||||||
|
): KandidatBewertung[] {
|
||||||
|
return vertraege
|
||||||
|
.map((vertrag) => bewerten(vertrag, heute))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Vorausgewaehlte zuerst, dann die moeglichen, dann der Rest -
|
||||||
|
// jeweils alphabetisch. Was zu tun ist, steht oben.
|
||||||
|
const rang = (k: KandidatBewertung) => (k.vorausgewaehlt ? 0 : k.moeglich ? 1 : 2)
|
||||||
|
if (rang(a) !== rang(b)) return rang(a) - rang(b)
|
||||||
|
return a.vertrag.bezeichnung.localeCompare(b.vertrag.bezeichnung, 'de')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wurde heute schon angefragt? Der Dublettenschutz fuer den zweiten Lauf.
|
||||||
|
*
|
||||||
|
* Bewusst tagesgenau und nicht "innerhalb der Sperrfrist": Wer denselben
|
||||||
|
* Lauf versehentlich zweimal startet, soll keine doppelten Dokumente
|
||||||
|
* bekommen. Wer nach einer Woche bewusst erneut anfragt (Brief verloren),
|
||||||
|
* soll es duerfen.
|
||||||
|
*/
|
||||||
|
export function heuteSchonAngefragt(
|
||||||
|
angefragtAm: Date | null,
|
||||||
|
heute: Date = heuteAlsTag(),
|
||||||
|
): boolean {
|
||||||
|
if (!angefragtAm) return false
|
||||||
|
return tageZwischen(angefragtAm, heute) === 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import archiver from 'archiver'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
import { ablegenMitTyp } from '@/lib/speicher'
|
||||||
|
import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
|
||||||
|
import {
|
||||||
|
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
|
||||||
|
rueckkaufswertAnschreiben,
|
||||||
|
wiedervorlageAm,
|
||||||
|
} from '@/lib/vertraege/rueckkaufswert'
|
||||||
|
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
|
||||||
|
import { heuteSchonAngefragt } from '@/lib/vertraege/serienabfrage'
|
||||||
|
import { briefDocxErzeugen } from '@/lib/schreiben/docx'
|
||||||
|
import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
|
||||||
|
import { dateinameFuerSchreiben } from '@/lib/schreiben/arten'
|
||||||
|
import { betreffAusText } from '@/lib/schreiben/entwurf'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Serienlauf: Rueckkaufswerte aller gewaehlten Vertraege anfragen.
|
||||||
|
*
|
||||||
|
* Je Vertrag entsteht das volle Paket aus Issue #4 - Word-Datei fuers
|
||||||
|
* ZIP, PDF in die Akte, rueckkaufswertAngefragtAm, Wiedervorlage,
|
||||||
|
* Protokolleintrag. Der Unterschied: alles in einem Lauf, und ein
|
||||||
|
* gescheiterter Vertrag reisst die uebrigen nicht mit.
|
||||||
|
*
|
||||||
|
* Kein Alles-oder-nichts heisst konkret: Jeder Vertrag laeuft fuer sich.
|
||||||
|
* Was gelingt, gilt; was scheitert, steht mit Grund im Ergebnis und im
|
||||||
|
* Begleitblatt des ZIP. Eine Transaktion ueber alle Vertraege waere die
|
||||||
|
* falsche Zusage - wenn der neunte Brief am Objektspeicher scheitert,
|
||||||
|
* sollen die acht fertigen nicht verworfen werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SerienErgebnisZeile = {
|
||||||
|
vertragId: string
|
||||||
|
bezeichnung: string
|
||||||
|
gesellschaft: string | null
|
||||||
|
/** 'erzeugt' | 'uebersprungen' | 'fehlgeschlagen' */
|
||||||
|
ausgang: 'erzeugt' | 'uebersprungen' | 'fehlgeschlagen'
|
||||||
|
grund: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerienErgebnis = {
|
||||||
|
zip: Buffer
|
||||||
|
dateiname: string
|
||||||
|
zeilen: SerienErgebnisZeile[]
|
||||||
|
erzeugt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serienlaufDurchfuehren(eingabe: {
|
||||||
|
betreuteId: string
|
||||||
|
vertragIds: string[]
|
||||||
|
stichtag: Date
|
||||||
|
benutzer: { id: string; name: string; email: string } & Absender
|
||||||
|
}): Promise<SerienErgebnis> {
|
||||||
|
const heute = heuteAlsTag()
|
||||||
|
const { betreuteId, benutzer } = eingabe
|
||||||
|
|
||||||
|
const betreute = await db.betreute.findFirst({
|
||||||
|
where: { id: betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!betreute) throw new Error('Die betreute Person gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
const kopf = briefkopfBauen({
|
||||||
|
betreute: oeffnen('Betreute', betreute),
|
||||||
|
benutzer,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere.
|
||||||
|
const vertraege = await db.vertrag.findMany({
|
||||||
|
where: { id: { in: eingabe.vertragIds }, betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
|
||||||
|
const zeilen: SerienErgebnisZeile[] = []
|
||||||
|
const dateien: { name: string; inhalt: Buffer }[] = []
|
||||||
|
|
||||||
|
for (const vertrag of vertraege) {
|
||||||
|
const basis = {
|
||||||
|
vertragId: vertrag.id,
|
||||||
|
bezeichnung: vertrag.bezeichnung,
|
||||||
|
gesellschaft: vertrag.gesellschaft,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dublettenschutz: Der zweite Lauf am selben Tag erzeugt nichts
|
||||||
|
// Neues, sondern sagt, dass es heute schon lief.
|
||||||
|
if (heuteSchonAngefragt(vertrag.rueckkaufswertAngefragtAm, heute)) {
|
||||||
|
zeilen.push({
|
||||||
|
...basis,
|
||||||
|
ausgang: 'uebersprungen',
|
||||||
|
grund: 'Heute bereits angefragt — kein zweites Schreiben erzeugt.',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const offen = oeffnen('Vertrag', vertrag)
|
||||||
|
const text = rueckkaufswertAnschreiben({
|
||||||
|
kopf,
|
||||||
|
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
|
||||||
|
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
|
||||||
|
vertragsnummer: offen.vertragsnummer,
|
||||||
|
stichtag: eingabe.stichtag,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
const betreff = betreffAusText(text) || `Rückkaufswert ${vertrag.bezeichnung}`
|
||||||
|
|
||||||
|
// --- Word-Datei fuers ZIP ------------------------------------------
|
||||||
|
const docx = await briefDocxErzeugen(text)
|
||||||
|
dateien.push({
|
||||||
|
name: dateinameFuerSchreiben('RUECKKAUFSWERT', vertrag.gesellschaft, heute, 'docx'),
|
||||||
|
inhalt: docx,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- PDF in die Akte -----------------------------------------------
|
||||||
|
const pdf = await briefPdfErzeugen(text, heute)
|
||||||
|
const ablage = await ablegenMitTyp(
|
||||||
|
betreuteId,
|
||||||
|
'dokumente',
|
||||||
|
dateinameFuerSchreiben('RUECKKAUFSWERT', vertrag.gesellschaft, heute, 'pdf'),
|
||||||
|
pdf,
|
||||||
|
'application/pdf',
|
||||||
|
)
|
||||||
|
|
||||||
|
const dokument = await db.dokument.create({
|
||||||
|
data: {
|
||||||
|
betreuteId,
|
||||||
|
titel: betreff.slice(0, 200),
|
||||||
|
art: 'SCHREIBEN',
|
||||||
|
richtung: 'AUSGANG',
|
||||||
|
schreibenVom: heute,
|
||||||
|
versandAm: heute,
|
||||||
|
empfaenger: vertrag.gesellschaft,
|
||||||
|
objektSchluessel: ablage.schluessel,
|
||||||
|
dateiname: ablage.dateiname,
|
||||||
|
mimeTyp: ablage.typ.mimeTyp,
|
||||||
|
groesseBytes: ablage.groesseBytes,
|
||||||
|
pruefsumme: ablage.pruefsumme,
|
||||||
|
ocrStatus: 'nicht_verfuegbar',
|
||||||
|
posteingangStatus: 'ZUGEORDNET',
|
||||||
|
verknuepfungen: { create: [{ betreuteId, vertragId: vertrag.id }] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Wiedervorlage und Anfragedatum --------------------------------
|
||||||
|
await db.frist.create({
|
||||||
|
data: {
|
||||||
|
betreuteId,
|
||||||
|
titel: `Antwort abwarten: Rückkaufswert ${vertrag.bezeichnung}`.slice(0, 200),
|
||||||
|
beschreibung:
|
||||||
|
`Am ${heute.toLocaleDateString('de-DE')} an ${vertrag.gesellschaft ?? 'die Gesellschaft'} ` +
|
||||||
|
`geschickt (Stichtag ${eingabe.stichtag.toLocaleDateString('de-DE')}). ` +
|
||||||
|
'Kommt bis zu diesem Tag nichts, nachfassen.',
|
||||||
|
datum: wiedervorlageAm(heute),
|
||||||
|
typ: 'WIEDERVORLAGE',
|
||||||
|
verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.vertrag.update({
|
||||||
|
where: { id: vertrag.id },
|
||||||
|
data: { rueckkaufswertAngefragtAm: heute },
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'Dokument',
|
||||||
|
entitaetId: dokument.id,
|
||||||
|
bezeichnung: `Serienabfrage Rückkaufswert: ${vertrag.bezeichnung}`,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
zeilen.push({ ...basis, ausgang: 'erzeugt', grund: null })
|
||||||
|
} catch (ausnahme) {
|
||||||
|
// Ein Vertrag scheitert, die uebrigen laufen weiter. Der Grund
|
||||||
|
// steht im Ergebnis - nichts verschwindet stumm.
|
||||||
|
console.error(`[serienlauf] Vertrag ${vertrag.id} fehlgeschlagen:`, ausnahme)
|
||||||
|
zeilen.push({
|
||||||
|
...basis,
|
||||||
|
ausgang: 'fehlgeschlagen',
|
||||||
|
grund: ausnahme instanceof Error ? ausnahme.message : 'Unbekannter Fehler.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDs, die zu keinem Vertrag der Person gehoeren - auch das wird
|
||||||
|
// benannt statt verschluckt.
|
||||||
|
const gefunden = new Set(vertraege.map((v) => v.id))
|
||||||
|
for (const id of eingabe.vertragIds) {
|
||||||
|
if (!gefunden.has(id)) {
|
||||||
|
zeilen.push({
|
||||||
|
vertragId: id,
|
||||||
|
bezeichnung: '(unbekannt)',
|
||||||
|
gesellschaft: null,
|
||||||
|
ausgang: 'fehlgeschlagen',
|
||||||
|
grund: 'Diesen Vertrag gibt es nicht (mehr).',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const erzeugt = zeilen.filter((z) => z.ausgang === 'erzeugt').length
|
||||||
|
|
||||||
|
// --- Begleitblatt und ZIP ----------------------------------------------
|
||||||
|
const begleitblatt = [
|
||||||
|
`Serienabfrage Rückkaufswerte vom ${heute.toLocaleDateString('de-DE')}`,
|
||||||
|
`Stichtag der Anfragen: ${eingabe.stichtag.toLocaleDateString('de-DE')}`,
|
||||||
|
`Wiedervorlage: ${wiedervorlageAm(heute).toLocaleDateString('de-DE')} (${RUECKKAUFSWERT_WIEDERVORLAGE_TAGE} Tage)`,
|
||||||
|
'',
|
||||||
|
...zeilen.map((zeile) => {
|
||||||
|
const stand =
|
||||||
|
zeile.ausgang === 'erzeugt'
|
||||||
|
? 'erzeugt'
|
||||||
|
: zeile.ausgang === 'uebersprungen'
|
||||||
|
? `übersprungen: ${zeile.grund}`
|
||||||
|
: `FEHLGESCHLAGEN: ${zeile.grund}`
|
||||||
|
return `- ${zeile.bezeichnung}${zeile.gesellschaft ? ` (${zeile.gesellschaft})` : ''} — ${stand}`
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
'Jedes erzeugte Schreiben liegt zusätzlich als PDF in der Akte (Dokumente, Richtung Ausgang).',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const zip = await zipBauen([
|
||||||
|
{ name: 'UEBERSICHT.txt', inhalt: Buffer.from(begleitblatt, 'utf8') },
|
||||||
|
...dateien,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
zip,
|
||||||
|
dateiname: `${heute.toISOString().slice(0, 10)}_Rueckkaufswert-Anfragen.zip`,
|
||||||
|
zeilen,
|
||||||
|
erzeugt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Packt die Dateien in ein ZIP im Speicher. Bei einer Handvoll Word-Dateien
|
||||||
|
* ist Streamen unnoetig - Einfachheit schlaegt hier Durchsatz. */
|
||||||
|
function zipBauen(dateien: { name: string; inhalt: Buffer }[]): Promise<Buffer> {
|
||||||
|
return new Promise((erfuellen, verwerfen) => {
|
||||||
|
const archiv = archiver('zip', { zlib: { level: 9 } })
|
||||||
|
const teile: Buffer[] = []
|
||||||
|
archiv.on('data', (teil: Buffer) => teile.push(teil))
|
||||||
|
archiv.on('error', verwerfen)
|
||||||
|
archiv.on('end', () => erfuellen(Buffer.concat(teile)))
|
||||||
|
for (const datei of dateien) {
|
||||||
|
archiv.append(datei.inhalt, { name: datei.name })
|
||||||
|
}
|
||||||
|
void archiv.finalize()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { differenceInCalendarDays } from 'date-fns'
|
import { differenceInCalendarDays } from 'date-fns'
|
||||||
|
import { anredeZeile, type Ansprechpartner } from '@/lib/kontakte/anrede'
|
||||||
import { formatDatum } from '@/lib/utils'
|
import { formatDatum } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,8 +37,12 @@ export type SachstandsanfrageEingabe = {
|
|||||||
aktenzeichenGericht?: string | null
|
aktenzeichenGericht?: string | null
|
||||||
betreuungsgericht?: string | null
|
betreuungsgericht?: string | null
|
||||||
}
|
}
|
||||||
/** Ansprechpartner bei der Gegenstelle, wenn bekannt. */
|
/**
|
||||||
ansprechpartner?: string | null
|
* Ansprechpartner bei der Gegenstelle, wenn am Vorgang benannt.
|
||||||
|
* Bestimmt die Anrede - ohne ihn bleibt es bei "Sehr geehrte Damen
|
||||||
|
* und Herren".
|
||||||
|
*/
|
||||||
|
ansprechpartner?: Ansprechpartner | null
|
||||||
/** Für den Test überschreibbar. */
|
/** Für den Test überschreibbar. */
|
||||||
heute?: Date
|
heute?: Date
|
||||||
}
|
}
|
||||||
@@ -69,13 +74,15 @@ export function sachstandsanfrageErzeugen(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const anrede = eingabe.ansprechpartner
|
const anrede = anredeZeile(eingabe.ansprechpartner ?? null)
|
||||||
? `Sehr geehrte(r) ${eingabe.ansprechpartner},`
|
|
||||||
: 'Sehr geehrte Damen und Herren,'
|
|
||||||
|
|
||||||
const zeilen: string[] = []
|
const zeilen: string[] = []
|
||||||
|
|
||||||
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle, '')
|
// Der Name steht ueber der Stelle, nicht darunter: So sortiert die
|
||||||
|
// Poststelle den Brief zur Person und nicht ins allgemeine Fach.
|
||||||
|
if (eingabe.ansprechpartner?.name.trim()) zeilen.push(eingabe.ansprechpartner.name.trim())
|
||||||
|
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle)
|
||||||
|
if (eingabe.ansprechpartner?.name.trim() || eingabe.gegenstelle) zeilen.push('')
|
||||||
zeilen.push(formatDatum(heute), '')
|
zeilen.push(formatDatum(heute), '')
|
||||||
zeilen.push(`Betreff: ${betreff}`, '')
|
zeilen.push(`Betreff: ${betreff}`, '')
|
||||||
zeilen.push(anrede, '')
|
zeilen.push(anrede, '')
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const PAPIERKORB_TABELLEN = [
|
|||||||
'bescheid',
|
'bescheid',
|
||||||
'leistung',
|
'leistung',
|
||||||
'bericht',
|
'bericht',
|
||||||
|
'schreibvorlage',
|
||||||
'betreute',
|
'betreute',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
+612
-612
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { verweisePruefen } from '@/lib/auth/verweise'
|
||||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||||
import { sealen } from '@/lib/crypto/feld'
|
import { sealen } from '@/lib/crypto/feld'
|
||||||
@@ -325,20 +326,15 @@ export async function buchungSpeichern(
|
|||||||
}
|
}
|
||||||
const werte = gepruft.data
|
const werte = gepruft.data
|
||||||
|
|
||||||
if (werte.kontoId) {
|
// Alle vier Verweise auf einmal - der Vorgang war bisher der einzige,
|
||||||
const konto = await db.konto.findFirst({
|
// der ungeprueft durchging.
|
||||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
const verweisfehler = await verweisePruefen(betreuteId, {
|
||||||
select: { id: true },
|
konto: werte.kontoId,
|
||||||
})
|
vertrag: werte.vertragId,
|
||||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
dokument: werte.belegId,
|
||||||
}
|
vorgang: werte.vorgangId,
|
||||||
if (werte.belegId) {
|
})
|
||||||
const beleg = await db.dokument.findFirst({
|
if (verweisfehler) return { fehler: verweisfehler }
|
||||||
where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (!beleg) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const felder = {
|
const felder = {
|
||||||
kontoId: werte.kontoId,
|
kontoId: werte.kontoId,
|
||||||
@@ -626,13 +622,14 @@ export async function vermoegenspostenSpeichern(
|
|||||||
}
|
}
|
||||||
const werte = gepruft.data
|
const werte = gepruft.data
|
||||||
|
|
||||||
if (werte.kontoId) {
|
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert - sie
|
||||||
const konto = await db.konto.findFirst({
|
// darf nicht in eine fremde Akte zeigen.
|
||||||
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
|
const verweisfehler = await verweisePruefen(betreuteId, {
|
||||||
select: { id: true },
|
konto: werte.kontoId,
|
||||||
})
|
vertrag: werte.vertragId,
|
||||||
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
|
immobilie: werte.immobilieId,
|
||||||
}
|
})
|
||||||
|
if (verweisfehler) return { fehler: verweisfehler }
|
||||||
|
|
||||||
const felder = {
|
const felder = {
|
||||||
bezeichnung: werte.bezeichnung,
|
bezeichnung: werte.bezeichnung,
|
||||||
|
|||||||
+385
-385
@@ -1,385 +1,385 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import type { Modul } from '@prisma/client'
|
import type { Modul } from '@prisma/client'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
import { passwortHashen } from '@/lib/crypto/hash'
|
import { passwortHashen } from '@/lib/crypto/hash'
|
||||||
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
|
||||||
import {
|
import {
|
||||||
benutzerAktivSchema,
|
benutzerAktivSchema,
|
||||||
benutzerAnlegenSchema,
|
benutzerAnlegenSchema,
|
||||||
darfBenutzerVerwalten,
|
darfBenutzerVerwalten,
|
||||||
darfFreigabenVergeben,
|
darfFreigabenVergeben,
|
||||||
freigabeAenderung,
|
freigabeAenderung,
|
||||||
freigabenNormalisieren,
|
freigabenNormalisieren,
|
||||||
freigabenSchema,
|
freigabenSchema,
|
||||||
letzterAdminGeschuetzt,
|
letzterAdminGeschuetzt,
|
||||||
rolleAendernSchema,
|
rolleAendernSchema,
|
||||||
sitzungenBeendenSchema,
|
sitzungenBeendenSchema,
|
||||||
} from '@/lib/validierung/freigaben'
|
} from '@/lib/validierung/freigaben'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Benutzerverwaltung und Modulfreigaben.
|
* Benutzerverwaltung und Modulfreigaben.
|
||||||
*
|
*
|
||||||
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
|
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
|
||||||
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
|
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
|
||||||
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
|
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
|
||||||
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
|
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
|
||||||
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
|
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
|
||||||
* wem wann was freigegeben hat.
|
* wem wann was freigegeben hat.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Aktionszustand = {
|
export type Aktionszustand = {
|
||||||
fehler?: string
|
fehler?: string
|
||||||
felder?: Record<string, string>
|
felder?: Record<string, string>
|
||||||
erfolg?: string
|
erfolg?: string
|
||||||
warnung?: string
|
warnung?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
|
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
|
||||||
const felder: Record<string, string> = {}
|
const felder: Record<string, string> = {}
|
||||||
for (const problem of fehler) {
|
for (const problem of fehler) {
|
||||||
const schluessel = String(problem.path[0] ?? 'formular')
|
const schluessel = String(problem.path[0] ?? 'formular')
|
||||||
if (!felder[schluessel]) felder[schluessel] = problem.message
|
if (!felder[schluessel]) felder[schluessel] = problem.message
|
||||||
}
|
}
|
||||||
return felder
|
return felder
|
||||||
}
|
}
|
||||||
|
|
||||||
async function administratorSein(weiter: string) {
|
async function administratorSein(weiter: string) {
|
||||||
const benutzer = await erfordereBenutzer(weiter)
|
const benutzer = await erfordereBenutzer(weiter)
|
||||||
if (!darfBenutzerVerwalten(benutzer.rolle)) {
|
if (!darfBenutzerVerwalten(benutzer.rolle)) {
|
||||||
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
|
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
|
||||||
}
|
}
|
||||||
await erfordereFrischeAnmeldung(benutzer, weiter)
|
await erfordereFrischeAnmeldung(benutzer, weiter)
|
||||||
return benutzer
|
return benutzer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function anzahlAktiverAdmins(): Promise<number> {
|
async function anzahlAktiverAdmins(): Promise<number> {
|
||||||
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
|
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Benutzer
|
// Benutzer
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function benutzerAnlegen(
|
export async function benutzerAnlegen(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const gelesen = benutzerAnlegenSchema.safeParse({
|
const gelesen = benutzerAnlegenSchema.safeParse({
|
||||||
name: formular.get('name'),
|
name: formular.get('name'),
|
||||||
email: formular.get('email'),
|
email: formular.get('email'),
|
||||||
rolle: formular.get('rolle'),
|
rolle: formular.get('rolle'),
|
||||||
passwort: formular.get('passwort'),
|
passwort: formular.get('passwort'),
|
||||||
})
|
})
|
||||||
if (!gelesen.success) {
|
if (!gelesen.success) {
|
||||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = await administratorSein('/einstellungen/benutzer')
|
const admin = await administratorSein('/einstellungen/benutzer')
|
||||||
|
|
||||||
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
|
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
|
||||||
if (vorhanden) {
|
if (vorhanden) {
|
||||||
return {
|
return {
|
||||||
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
|
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
|
||||||
felder: { email: 'Bereits vergeben.' },
|
felder: { email: 'Bereits vergeben.' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const neu = await db.user.create({
|
const neu = await db.user.create({
|
||||||
data: {
|
data: {
|
||||||
name: gelesen.data.name,
|
name: gelesen.data.name,
|
||||||
email: gelesen.data.email,
|
email: gelesen.data.email,
|
||||||
rolle: gelesen.data.rolle,
|
rolle: gelesen.data.rolle,
|
||||||
passwortHash: await passwortHashen(gelesen.data.passwort),
|
passwortHash: await passwortHashen(gelesen.data.passwort),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'ERSTELLT',
|
aktion: 'ERSTELLT',
|
||||||
entitaet: 'User',
|
entitaet: 'User',
|
||||||
entitaetId: neu.id,
|
entitaetId: neu.id,
|
||||||
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
|
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
|
||||||
userId: admin.id,
|
userId: admin.id,
|
||||||
userEmail: admin.email,
|
userEmail: admin.email,
|
||||||
userName: admin.name,
|
userName: admin.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/benutzer')
|
revalidatePath('/einstellungen/benutzer')
|
||||||
return {
|
return {
|
||||||
erfolg:
|
erfolg:
|
||||||
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
|
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
|
||||||
`werden – ohne ihn kommt niemand an Fachdaten.`,
|
`werden – ohne ihn kommt niemand an Fachdaten.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rolleAendern(
|
export async function rolleAendern(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const gelesen = rolleAendernSchema.safeParse({
|
const gelesen = rolleAendernSchema.safeParse({
|
||||||
userId: formular.get('userId'),
|
userId: formular.get('userId'),
|
||||||
rolle: formular.get('rolle'),
|
rolle: formular.get('rolle'),
|
||||||
})
|
})
|
||||||
if (!gelesen.success) {
|
if (!gelesen.success) {
|
||||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = await administratorSein('/einstellungen/benutzer')
|
const admin = await administratorSein('/einstellungen/benutzer')
|
||||||
|
|
||||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||||
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
|
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
|
||||||
|
|
||||||
const schutz = letzterAdminGeschuetzt({
|
const schutz = letzterAdminGeschuetzt({
|
||||||
zielRolleVorher: ziel.rolle,
|
zielRolleVorher: ziel.rolle,
|
||||||
zielRolleNachher: gelesen.data.rolle,
|
zielRolleNachher: gelesen.data.rolle,
|
||||||
zielBleibtAktiv: ziel.aktiv,
|
zielBleibtAktiv: ziel.aktiv,
|
||||||
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
||||||
})
|
})
|
||||||
if (schutz) return { fehler: schutz }
|
if (schutz) return { fehler: schutz }
|
||||||
|
|
||||||
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
|
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
|
||||||
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
|
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: ziel.id },
|
where: { id: ziel.id },
|
||||||
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
|
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'ROLLE_GEAENDERT',
|
aktion: 'ROLLE_GEAENDERT',
|
||||||
entitaet: 'User',
|
entitaet: 'User',
|
||||||
entitaetId: ziel.id,
|
entitaetId: ziel.id,
|
||||||
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
|
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
|
||||||
userId: admin.id,
|
userId: admin.id,
|
||||||
userEmail: admin.email,
|
userEmail: admin.email,
|
||||||
userName: admin.name,
|
userName: admin.name,
|
||||||
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
|
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/benutzer')
|
revalidatePath('/einstellungen/benutzer')
|
||||||
return {
|
return {
|
||||||
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
|
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function benutzerAktivSetzen(
|
export async function benutzerAktivSetzen(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const gelesen = benutzerAktivSchema.safeParse({
|
const gelesen = benutzerAktivSchema.safeParse({
|
||||||
userId: formular.get('userId'),
|
userId: formular.get('userId'),
|
||||||
aktiv: formular.get('aktiv') === 'ja',
|
aktiv: formular.get('aktiv') === 'ja',
|
||||||
})
|
})
|
||||||
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
||||||
|
|
||||||
const admin = await administratorSein('/einstellungen/benutzer')
|
const admin = await administratorSein('/einstellungen/benutzer')
|
||||||
|
|
||||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||||
if (ziel.id === admin.id && !gelesen.data.aktiv) {
|
if (ziel.id === admin.id && !gelesen.data.aktiv) {
|
||||||
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
|
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const schutz = letzterAdminGeschuetzt({
|
const schutz = letzterAdminGeschuetzt({
|
||||||
zielRolleVorher: ziel.rolle,
|
zielRolleVorher: ziel.rolle,
|
||||||
zielRolleNachher: ziel.rolle,
|
zielRolleNachher: ziel.rolle,
|
||||||
zielBleibtAktiv: gelesen.data.aktiv,
|
zielBleibtAktiv: gelesen.data.aktiv,
|
||||||
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
|
||||||
})
|
})
|
||||||
if (schutz) return { fehler: schutz }
|
if (schutz) return { fehler: schutz }
|
||||||
|
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: ziel.id },
|
where: { id: ziel.id },
|
||||||
data: {
|
data: {
|
||||||
aktiv: gelesen.data.aktiv,
|
aktiv: gelesen.data.aktiv,
|
||||||
// Wer abgeschaltet wird, ist sofort abgemeldet.
|
// Wer abgeschaltet wird, ist sofort abgemeldet.
|
||||||
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
|
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'GEAENDERT',
|
aktion: 'GEAENDERT',
|
||||||
entitaet: 'User',
|
entitaet: 'User',
|
||||||
entitaetId: ziel.id,
|
entitaetId: ziel.id,
|
||||||
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
|
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
|
||||||
userId: admin.id,
|
userId: admin.id,
|
||||||
userEmail: admin.email,
|
userEmail: admin.email,
|
||||||
userName: admin.name,
|
userName: admin.name,
|
||||||
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
|
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/benutzer')
|
revalidatePath('/einstellungen/benutzer')
|
||||||
return {
|
return {
|
||||||
erfolg: gelesen.data.aktiv
|
erfolg: gelesen.data.aktiv
|
||||||
? `${ziel.name} kann sich wieder anmelden.`
|
? `${ziel.name} kann sich wieder anmelden.`
|
||||||
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
|
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sitzungenBeenden(
|
export async function sitzungenBeenden(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
|
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
|
||||||
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
|
||||||
|
|
||||||
const admin = await administratorSein('/einstellungen/benutzer')
|
const admin = await administratorSein('/einstellungen/benutzer')
|
||||||
|
|
||||||
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
|
||||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||||
|
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: ziel.id },
|
where: { id: ziel.id },
|
||||||
data: { sitzungsGeneration: { increment: 1 } },
|
data: { sitzungsGeneration: { increment: 1 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'GEAENDERT',
|
aktion: 'GEAENDERT',
|
||||||
entitaet: 'User',
|
entitaet: 'User',
|
||||||
entitaetId: ziel.id,
|
entitaetId: ziel.id,
|
||||||
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
|
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
|
||||||
userId: admin.id,
|
userId: admin.id,
|
||||||
userEmail: admin.email,
|
userEmail: admin.email,
|
||||||
userName: admin.name,
|
userName: admin.name,
|
||||||
aenderung: {
|
aenderung: {
|
||||||
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
|
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/benutzer')
|
revalidatePath('/einstellungen/benutzer')
|
||||||
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
|
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Freigaben je betreuter Person
|
// Freigaben je betreuter Person
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function freigabenSpeichern(
|
export async function freigabenSpeichern(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const gelesen = freigabenSchema.safeParse({
|
const gelesen = freigabenSchema.safeParse({
|
||||||
userId: formular.get('userId'),
|
userId: formular.get('userId'),
|
||||||
betreuteId: formular.get('betreuteId'),
|
betreuteId: formular.get('betreuteId'),
|
||||||
rolle: formular.get('rolle'),
|
rolle: formular.get('rolle'),
|
||||||
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
|
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
|
||||||
})
|
})
|
||||||
if (!gelesen.success) {
|
if (!gelesen.success) {
|
||||||
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, betreuteId, rolle } = gelesen.data
|
const { userId, betreuteId, rolle } = gelesen.data
|
||||||
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
||||||
|
|
||||||
if (!darfFreigabenVergeben(zugang)) {
|
if (!darfFreigabenVergeben(zugang)) {
|
||||||
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
|
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
|
||||||
}
|
}
|
||||||
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
||||||
|
|
||||||
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
|
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
|
||||||
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
|
||||||
|
|
||||||
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
|
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
|
||||||
|
|
||||||
const vorher = await db.betreuungsZugang.findUnique({
|
const vorher = await db.betreuungsZugang.findUnique({
|
||||||
where: { userId_betreuteId: { userId, betreuteId } },
|
where: { userId_betreuteId: { userId, betreuteId } },
|
||||||
select: { rolle: true, freigegebeneModule: true },
|
select: { rolle: true, freigegebeneModule: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const aenderung = freigabeAenderung(
|
const aenderung = freigabeAenderung(
|
||||||
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
|
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
|
||||||
{ rolle, freigegebeneModule: freigaben },
|
{ rolle, freigegebeneModule: freigaben },
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.betreuungsZugang.upsert({
|
await db.betreuungsZugang.upsert({
|
||||||
where: { userId_betreuteId: { userId, betreuteId } },
|
where: { userId_betreuteId: { userId, betreuteId } },
|
||||||
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
|
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
|
||||||
update: { rolle, freigegebeneModule: freigaben },
|
update: { rolle, freigegebeneModule: freigaben },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
|
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
|
||||||
// weiterbenutzen koennen.
|
// weiterbenutzen koennen.
|
||||||
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
|
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
|
||||||
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const beschreibung = [
|
const beschreibung = [
|
||||||
aenderung.rolleGeaendert
|
aenderung.rolleGeaendert
|
||||||
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
|
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
|
||||||
: null,
|
: null,
|
||||||
aenderung.hinzugefuegt.length > 0
|
aenderung.hinzugefuegt.length > 0
|
||||||
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
||||||
: null,
|
: null,
|
||||||
aenderung.entfernt.length > 0
|
aenderung.entfernt.length > 0
|
||||||
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('; ')
|
.join('; ')
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
|
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
|
||||||
entitaet: 'BetreuungsZugang',
|
entitaet: 'BetreuungsZugang',
|
||||||
entitaetId: userId,
|
entitaetId: userId,
|
||||||
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
|
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
|
||||||
betreuteId,
|
betreuteId,
|
||||||
userId: benutzer.id,
|
userId: benutzer.id,
|
||||||
userEmail: benutzer.email,
|
userEmail: benutzer.email,
|
||||||
userName: benutzer.name,
|
userName: benutzer.name,
|
||||||
aenderung: {
|
aenderung: {
|
||||||
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
|
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
|
||||||
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
|
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/freigaben')
|
revalidatePath('/einstellungen/freigaben')
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
|
|
||||||
const warnung =
|
const warnung =
|
||||||
aenderung.sensibelHinzugefuegt.length > 0
|
aenderung.sensibelHinzugefuegt.length > 0
|
||||||
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
|
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
|
||||||
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
|
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
|
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function zugangEntziehen(
|
export async function zugangEntziehen(
|
||||||
_zustand: Aktionszustand,
|
_zustand: Aktionszustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<Aktionszustand> {
|
): Promise<Aktionszustand> {
|
||||||
const userId = String(formular.get('userId') ?? '')
|
const userId = String(formular.get('userId') ?? '')
|
||||||
const betreuteId = String(formular.get('betreuteId') ?? '')
|
const betreuteId = String(formular.get('betreuteId') ?? '')
|
||||||
|
|
||||||
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
|
||||||
if (!darfFreigabenVergeben(zugang)) {
|
if (!darfFreigabenVergeben(zugang)) {
|
||||||
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
|
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
|
||||||
}
|
}
|
||||||
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
|
||||||
|
|
||||||
if (userId === benutzer.id) {
|
if (userId === benutzer.id) {
|
||||||
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
|
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
|
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
|
||||||
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
|
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
|
||||||
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'FREIGABE_GEAENDERT',
|
aktion: 'FREIGABE_GEAENDERT',
|
||||||
entitaet: 'BetreuungsZugang',
|
entitaet: 'BetreuungsZugang',
|
||||||
entitaetId: userId,
|
entitaetId: userId,
|
||||||
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
|
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
|
||||||
betreuteId,
|
betreuteId,
|
||||||
userId: benutzer.id,
|
userId: benutzer.id,
|
||||||
userEmail: benutzer.email,
|
userEmail: benutzer.email,
|
||||||
userName: benutzer.name,
|
userName: benutzer.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath('/einstellungen/freigaben')
|
revalidatePath('/einstellungen/freigaben')
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
|
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
||||||
@@ -102,3 +103,82 @@ export async function passwortAendern(
|
|||||||
await aufMindestdauerWarten(begonnen)
|
await aufMindestdauerWarten(begonnen)
|
||||||
return { erfolg: true }
|
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
-285
@@ -1,285 +1,293 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import type { Modul } from '@prisma/client'
|
import type { Modul } from '@prisma/client'
|
||||||
import { db, papierkorbGrenze } from '@/lib/db'
|
import { db, papierkorbGrenze } from '@/lib/db'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { entfernen } from '@/lib/speicher'
|
import { entfernen } from '@/lib/speicher'
|
||||||
import { papierkorbSchema } from '@/lib/validierung/freigaben'
|
import { papierkorbSchema } from '@/lib/validierung/freigaben'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Papierkorb.
|
* Papierkorb.
|
||||||
*
|
*
|
||||||
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
|
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
|
||||||
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
|
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
|
||||||
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
|
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
|
||||||
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
|
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
|
||||||
*
|
*
|
||||||
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
|
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
|
||||||
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
|
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
|
||||||
* es ihn gab und wer ihn entfernt hat.
|
* es ihn gab und wer ihn entfernt hat.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Aktionszustand = {
|
export type Aktionszustand = {
|
||||||
fehler?: string
|
fehler?: string
|
||||||
erfolg?: string
|
erfolg?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
|
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
|
||||||
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
|
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
|
||||||
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
|
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
|
||||||
*/
|
*/
|
||||||
type Eintragsart = {
|
type Eintragsart = {
|
||||||
bezeichnung: string
|
bezeichnung: string
|
||||||
mehrzahl: string
|
mehrzahl: string
|
||||||
modul: Modul
|
modul: Modul
|
||||||
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
|
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
|
||||||
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
|
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
|
||||||
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
|
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PapierkorbEintrag = {
|
export type PapierkorbEintrag = {
|
||||||
id: string
|
id: string
|
||||||
entitaet: string
|
entitaet: string
|
||||||
bezeichnung: string
|
bezeichnung: string
|
||||||
geloeschtAm: Date
|
geloeschtAm: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
|
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
|
||||||
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
|
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
|
||||||
entitaet: string,
|
entitaet: string,
|
||||||
bezeichnung: string,
|
bezeichnung: string,
|
||||||
mehrzahl: string,
|
mehrzahl: string,
|
||||||
modul: Modul,
|
modul: Modul,
|
||||||
zugriff: {
|
zugriff: {
|
||||||
findMany: (args: unknown) => Promise<T[]>
|
findMany: (args: unknown) => Promise<T[]>
|
||||||
update: (args: unknown) => Promise<unknown>
|
update: (args: unknown) => Promise<unknown>
|
||||||
delete: (args: unknown) => Promise<unknown>
|
delete: (args: unknown) => Promise<unknown>
|
||||||
},
|
},
|
||||||
titelFeld: string,
|
titelFeld: string,
|
||||||
): Eintragsart {
|
): Eintragsart {
|
||||||
return {
|
return {
|
||||||
bezeichnung,
|
bezeichnung,
|
||||||
mehrzahl,
|
mehrzahl,
|
||||||
modul,
|
modul,
|
||||||
laden: async (betreuteId, seit) => {
|
laden: async (betreuteId, seit) => {
|
||||||
const treffer = await zugriff.findMany({
|
const treffer = await zugriff.findMany({
|
||||||
where: { betreuteId, geloeschtAm: { gte: seit } },
|
where: { betreuteId, geloeschtAm: { gte: seit } },
|
||||||
orderBy: { geloeschtAm: 'desc' },
|
orderBy: { geloeschtAm: 'desc' },
|
||||||
})
|
})
|
||||||
return treffer.map((datensatz) => ({
|
return treffer.map((datensatz) => ({
|
||||||
id: datensatz.id,
|
id: datensatz.id,
|
||||||
entitaet,
|
entitaet,
|
||||||
bezeichnung:
|
bezeichnung:
|
||||||
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
|
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
|
||||||
geloeschtAm: datensatz.geloeschtAm!,
|
geloeschtAm: datensatz.geloeschtAm!,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
wiederherstellen: async (id, betreuteId) => {
|
wiederherstellen: async (id, betreuteId) => {
|
||||||
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
|
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
|
||||||
},
|
},
|
||||||
endgueltigLoeschen: async (id, betreuteId) => {
|
endgueltigLoeschen: async (id, betreuteId) => {
|
||||||
await zugriff.delete({ where: { id, betreuteId } })
|
await zugriff.delete({ where: { id, betreuteId } })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function eintragsarten(): Record<string, Eintragsart> {
|
function eintragsarten(): Record<string, Eintragsart> {
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
const z = db as any
|
const z = db as any
|
||||||
return {
|
return {
|
||||||
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
|
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
|
||||||
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
|
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
|
||||||
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
|
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
|
||||||
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
|
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
|
||||||
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
|
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
|
||||||
Vermoegensposten: einfach(
|
Vermoegensposten: einfach(
|
||||||
'Vermoegensposten',
|
'Vermoegensposten',
|
||||||
'Vermögensposten',
|
'Vermögensposten',
|
||||||
'Vermögensposten',
|
'Vermögensposten',
|
||||||
'FINANZEN',
|
'FINANZEN',
|
||||||
z.vermoegensposten,
|
z.vermoegensposten,
|
||||||
'bezeichnung',
|
'bezeichnung',
|
||||||
),
|
),
|
||||||
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
|
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
|
||||||
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
|
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
|
||||||
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
|
Schreibvorlage: einfach(
|
||||||
Kontaktprotokoll: einfach(
|
'Schreibvorlage',
|
||||||
'Kontaktprotokoll',
|
'Vorlage',
|
||||||
'Kontaktprotokoll',
|
'Vorlagen',
|
||||||
'Kontaktprotokolle',
|
'DOKUMENTE',
|
||||||
'KONTAKTE',
|
z.schreibvorlage,
|
||||||
z.kontaktprotokoll,
|
'name',
|
||||||
'gespraechspartner',
|
),
|
||||||
),
|
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
|
||||||
Gesundheitsereignis: einfach(
|
Kontaktprotokoll: einfach(
|
||||||
'Gesundheitsereignis',
|
'Kontaktprotokoll',
|
||||||
'Gesundheitsereignis',
|
'Kontaktprotokoll',
|
||||||
'Gesundheitsereignisse',
|
'Kontaktprotokolle',
|
||||||
'GESUNDHEIT',
|
'KONTAKTE',
|
||||||
z.gesundheitsereignis,
|
z.kontaktprotokoll,
|
||||||
'titel',
|
'gespraechspartner',
|
||||||
),
|
),
|
||||||
Genehmigung: einfach(
|
Gesundheitsereignis: einfach(
|
||||||
'Genehmigung',
|
'Gesundheitsereignis',
|
||||||
'Genehmigung',
|
'Gesundheitsereignis',
|
||||||
'Genehmigungen',
|
'Gesundheitsereignisse',
|
||||||
'VORGAENGE',
|
'GESUNDHEIT',
|
||||||
z.genehmigung,
|
z.gesundheitsereignis,
|
||||||
'titel',
|
'titel',
|
||||||
),
|
),
|
||||||
SozialhilfeAntrag: einfach(
|
Genehmigung: einfach(
|
||||||
'SozialhilfeAntrag',
|
'Genehmigung',
|
||||||
'Sozialhilfeantrag',
|
'Genehmigung',
|
||||||
'Sozialhilfeanträge',
|
'Genehmigungen',
|
||||||
'SOZIALHILFE',
|
'VORGAENGE',
|
||||||
z.sozialhilfeAntrag,
|
z.genehmigung,
|
||||||
'leistungsart',
|
'titel',
|
||||||
),
|
),
|
||||||
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
|
SozialhilfeAntrag: einfach(
|
||||||
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
|
'SozialhilfeAntrag',
|
||||||
}
|
'Sozialhilfeantrag',
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
'Sozialhilfeanträge',
|
||||||
}
|
'SOZIALHILFE',
|
||||||
|
z.sozialhilfeAntrag,
|
||||||
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
|
'leistungsart',
|
||||||
export async function papierkorbLaden(betreuteId: string): Promise<
|
),
|
||||||
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
|
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
|
||||||
> {
|
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
|
||||||
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
|
}
|
||||||
const seit = papierkorbGrenze()
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
}
|
||||||
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
|
|
||||||
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
|
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
|
||||||
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
|
export async function papierkorbLaden(betreuteId: string): Promise<
|
||||||
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
|
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
|
||||||
continue
|
> {
|
||||||
}
|
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
|
||||||
const eintraege = await beschreibung.laden(betreuteId, seit)
|
const seit = papierkorbGrenze()
|
||||||
if (eintraege.length > 0) {
|
|
||||||
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
|
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.
|
||||||
return gruppen
|
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
/**
|
const eintraege = await beschreibung.laden(betreuteId, seit)
|
||||||
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
|
if (eintraege.length > 0) {
|
||||||
*
|
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
|
||||||
* 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
|
return gruppen
|
||||||
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
|
}
|
||||||
*/
|
|
||||||
async function objektSchluesselFinden(
|
/**
|
||||||
entitaet: string,
|
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
|
||||||
entitaetId: string,
|
*
|
||||||
betreuteId: string,
|
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
|
||||||
): Promise<string | null> {
|
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
|
||||||
if (entitaet === 'Dokument') {
|
* eigene Signaturen, und eine Variable, die mal das eine und mal das
|
||||||
const dokument = await db.dokument.findFirst({
|
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
|
||||||
where: { id: entitaetId, betreuteId },
|
*/
|
||||||
select: { objektSchluessel: true },
|
async function objektSchluesselFinden(
|
||||||
})
|
entitaet: string,
|
||||||
return dokument?.objektSchluessel ?? null
|
entitaetId: string,
|
||||||
}
|
betreuteId: string,
|
||||||
if (entitaet === 'Bericht') {
|
): Promise<string | null> {
|
||||||
const bericht = await db.bericht.findFirst({
|
if (entitaet === 'Dokument') {
|
||||||
where: { id: entitaetId, betreuteId },
|
const dokument = await db.dokument.findFirst({
|
||||||
select: { objektSchluessel: true },
|
where: { id: entitaetId, betreuteId },
|
||||||
})
|
select: { objektSchluessel: true },
|
||||||
return bericht?.objektSchluessel ?? null
|
})
|
||||||
}
|
return dokument?.objektSchluessel ?? null
|
||||||
return null
|
}
|
||||||
}
|
if (entitaet === 'Bericht') {
|
||||||
|
const bericht = await db.bericht.findFirst({
|
||||||
export async function wiederherstellen(
|
where: { id: entitaetId, betreuteId },
|
||||||
_zustand: Aktionszustand,
|
select: { objektSchluessel: true },
|
||||||
formular: FormData,
|
})
|
||||||
): Promise<Aktionszustand> {
|
return bericht?.objektSchluessel ?? null
|
||||||
const gelesen = papierkorbSchema.safeParse({
|
}
|
||||||
betreuteId: formular.get('betreuteId'),
|
return null
|
||||||
entitaet: formular.get('entitaet'),
|
}
|
||||||
entitaetId: formular.get('entitaetId'),
|
|
||||||
})
|
export async function wiederherstellen(
|
||||||
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
_zustand: Aktionszustand,
|
||||||
|
formular: FormData,
|
||||||
const art = eintragsarten()[gelesen.data.entitaet]
|
): Promise<Aktionszustand> {
|
||||||
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
const gelesen = papierkorbSchema.safeParse({
|
||||||
|
betreuteId: formular.get('betreuteId'),
|
||||||
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
entitaet: formular.get('entitaet'),
|
||||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
|
entitaetId: formular.get('entitaetId'),
|
||||||
|
})
|
||||||
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
||||||
|
|
||||||
await protokollieren({
|
const art = eintragsarten()[gelesen.data.entitaet]
|
||||||
aktion: 'WIEDERHERGESTELLT',
|
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
||||||
entitaet: gelesen.data.entitaet,
|
|
||||||
entitaetId: gelesen.data.entitaetId,
|
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
||||||
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
|
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
|
||||||
betreuteId: gelesen.data.betreuteId,
|
|
||||||
userId: benutzer.id,
|
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
||||||
userEmail: benutzer.email,
|
|
||||||
userName: benutzer.name,
|
await protokollieren({
|
||||||
})
|
aktion: 'WIEDERHERGESTELLT',
|
||||||
|
entitaet: gelesen.data.entitaet,
|
||||||
revalidatePath('/einstellungen/papierkorb')
|
entitaetId: gelesen.data.entitaetId,
|
||||||
return { erfolg: `${art.bezeichnung} ist wieder da.` }
|
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
|
||||||
}
|
betreuteId: gelesen.data.betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
/**
|
userEmail: benutzer.email,
|
||||||
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
|
userName: benutzer.name,
|
||||||
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
|
})
|
||||||
*/
|
|
||||||
export async function endgueltigLoeschen(
|
revalidatePath('/einstellungen/papierkorb')
|
||||||
_zustand: Aktionszustand,
|
return { erfolg: `${art.bezeichnung} ist wieder da.` }
|
||||||
formular: FormData,
|
}
|
||||||
): Promise<Aktionszustand> {
|
|
||||||
const gelesen = papierkorbSchema.safeParse({
|
/**
|
||||||
betreuteId: formular.get('betreuteId'),
|
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
|
||||||
entitaet: formular.get('entitaet'),
|
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
|
||||||
entitaetId: formular.get('entitaetId'),
|
*/
|
||||||
})
|
export async function endgueltigLoeschen(
|
||||||
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
_zustand: Aktionszustand,
|
||||||
|
formular: FormData,
|
||||||
const art = eintragsarten()[gelesen.data.entitaet]
|
): Promise<Aktionszustand> {
|
||||||
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
const gelesen = papierkorbSchema.safeParse({
|
||||||
|
betreuteId: formular.get('betreuteId'),
|
||||||
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
entitaet: formular.get('entitaet'),
|
||||||
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
|
entitaetId: formular.get('entitaetId'),
|
||||||
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
|
})
|
||||||
|
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
|
||||||
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
|
|
||||||
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
|
const art = eintragsarten()[gelesen.data.entitaet]
|
||||||
// die ausdruecklich verschwinden sollten.
|
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
|
||||||
const schluessel = await objektSchluesselFinden(
|
|
||||||
gelesen.data.entitaet,
|
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
|
||||||
gelesen.data.entitaetId,
|
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
|
||||||
gelesen.data.betreuteId,
|
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
|
||||||
)
|
|
||||||
if (schluessel) {
|
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
|
||||||
try {
|
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
|
||||||
await entfernen(schluessel)
|
// die ausdruecklich verschwinden sollten.
|
||||||
} catch (fehler) {
|
const schluessel = await objektSchluesselFinden(
|
||||||
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
|
gelesen.data.entitaet,
|
||||||
}
|
gelesen.data.entitaetId,
|
||||||
}
|
gelesen.data.betreuteId,
|
||||||
|
)
|
||||||
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
if (schluessel) {
|
||||||
|
try {
|
||||||
await protokollieren({
|
await entfernen(schluessel)
|
||||||
aktion: 'ENDGUELTIG_GELOESCHT',
|
} catch (fehler) {
|
||||||
entitaet: gelesen.data.entitaet,
|
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
|
||||||
entitaetId: gelesen.data.entitaetId,
|
}
|
||||||
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
|
}
|
||||||
betreuteId: gelesen.data.betreuteId,
|
|
||||||
userId: benutzer.id,
|
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
|
||||||
userEmail: benutzer.email,
|
|
||||||
userName: benutzer.name,
|
await protokollieren({
|
||||||
})
|
aktion: 'ENDGUELTIG_GELOESCHT',
|
||||||
|
entitaet: gelesen.data.entitaet,
|
||||||
revalidatePath('/einstellungen/papierkorb')
|
entitaetId: gelesen.data.entitaetId,
|
||||||
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
|
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.` }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
import { ablegenMitTyp } from '@/lib/speicher'
|
||||||
|
import { heuteAlsTag } from '@/lib/fristen/datum'
|
||||||
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
|
import {
|
||||||
|
SCHREIBEN_EIGENSCHAFTEN,
|
||||||
|
dateinameFuerSchreiben,
|
||||||
|
istSchreibenArt,
|
||||||
|
type SchreibenArt,
|
||||||
|
} from '@/lib/schreiben/arten'
|
||||||
|
import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
|
||||||
|
import { emailFassung } from '@/lib/schreiben/email'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions rund um erzeugte Schreiben.
|
||||||
|
*
|
||||||
|
* Der Kern ist "In die Akte legen". Bisher endete ein Schreiben beim Text
|
||||||
|
* zum Kopieren; alles danach - ausdrucken, abschicken, als Dokument
|
||||||
|
* nachtragen, Wiedervorlage setzen - war Handarbeit, und der letzte
|
||||||
|
* Schritt wurde am haeufigsten vergessen. Dann fehlt der Nachweis genau
|
||||||
|
* dort, wo er gebraucht wird: im Bericht ans Gericht.
|
||||||
|
*
|
||||||
|
* Hier passiert alles in einem Zug, und zwar so, dass ein Abbruch nichts
|
||||||
|
* Halbes hinterlaesst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SchreibenZustand = {
|
||||||
|
fehler?: string
|
||||||
|
erfolg?: string
|
||||||
|
/** Kurze Aufzaehlung dessen, was angelegt wurde. */
|
||||||
|
meldung?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function zugriffsfehler(ausnahme: unknown): SchreibenZustand | null {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ablegenSchema = z.object({
|
||||||
|
betreuteId: z.string().min(1),
|
||||||
|
art: z.string().refine(istSchreibenArt, 'Unbekannte Art von Schreiben.'),
|
||||||
|
betreff: z.string().trim().min(1, 'Bitte einen Betreff eintragen.').max(200),
|
||||||
|
text: z.string().trim().min(30, 'Der Text ist zu kurz für ein Schreiben.').max(50_000),
|
||||||
|
empfaengerName: z.string().trim().max(200).default(''),
|
||||||
|
versandAm: z.string().trim().default(''),
|
||||||
|
wiedervorlageAm: z.string().trim().default(''),
|
||||||
|
vertragId: z.string().trim().default(''),
|
||||||
|
vorgangId: z.string().trim().default(''),
|
||||||
|
/** "BRIEF" oder "EMAIL" - bestimmt Fassung und Kennzeichnung. */
|
||||||
|
versandweg: z.enum(['BRIEF', 'EMAIL']).default('BRIEF'),
|
||||||
|
empfaengerEmail: z.string().trim().max(200).default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt das Schreiben als Ausgangsdokument in die Akte und setzt die
|
||||||
|
* Wiedervorlage.
|
||||||
|
*
|
||||||
|
* Reihenfolge mit Absicht: Erst die Datei in den Objektspeicher, dann der
|
||||||
|
* Datensatz. Bricht das Ablegen ab, gibt es keinen Dokumentdatensatz, der
|
||||||
|
* auf eine Datei zeigt, die es nicht gibt. Umgekehrt bliebe im
|
||||||
|
* schlimmsten Fall eine verwaiste Datei liegen - die kostet Platz, aber
|
||||||
|
* sie luegt niemanden an.
|
||||||
|
*/
|
||||||
|
export async function schreibenAblegen(
|
||||||
|
_zustand: SchreibenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<SchreibenZustand> {
|
||||||
|
const gelesen = ablegenSchema.safeParse({
|
||||||
|
betreuteId: daten.get('betreuteId'),
|
||||||
|
art: daten.get('art'),
|
||||||
|
betreff: daten.get('betreff'),
|
||||||
|
text: daten.get('text'),
|
||||||
|
empfaengerName: daten.get('empfaengerName') ?? '',
|
||||||
|
versandweg: daten.get('versandweg') ?? 'BRIEF',
|
||||||
|
empfaengerEmail: daten.get('empfaengerEmail') ?? '',
|
||||||
|
versandAm: daten.get('versandAm') ?? '',
|
||||||
|
wiedervorlageAm: daten.get('wiedervorlageAm') ?? '',
|
||||||
|
vertragId: daten.get('vertragId') ?? '',
|
||||||
|
vorgangId: daten.get('vorgangId') ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = gelesen.data
|
||||||
|
const art = werte.art as SchreibenArt
|
||||||
|
const betreuteId = werte.betreuteId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
|
||||||
|
|
||||||
|
const versandAm = tagAusText(werte.versandAm) ?? heuteAlsTag()
|
||||||
|
|
||||||
|
// --- Bezuege pruefen, bevor irgendetwas geschrieben wird ---------------
|
||||||
|
const vertrag = werte.vertragId
|
||||||
|
? await db.vertrag.findFirst({
|
||||||
|
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (werte.vertragId && !vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
const vorgang = werte.vorgangId
|
||||||
|
? await db.vorgang.findFirst({
|
||||||
|
where: { id: werte.vorgangId, betreuteId, ...nichtGeloescht },
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
// --- Datei zuerst -------------------------------------------------------
|
||||||
|
// Bei einer E-Mail wird die E-Mail-Fassung abgelegt, nicht der
|
||||||
|
// Brieftext: In der Akte soll stehen, was tatsaechlich hinausging -
|
||||||
|
// samt Empfaengeradresse im Kopf der PDF.
|
||||||
|
const istEmail = werte.versandweg === 'EMAIL'
|
||||||
|
const fassung = istEmail ? emailFassung(werte.text, werte.betreff) : null
|
||||||
|
const ablageText = fassung
|
||||||
|
? [
|
||||||
|
`E-Mail an: ${werte.empfaengerEmail || werte.empfaengerName || '[Adresse]'}`,
|
||||||
|
`Betreff: ${fassung.betreff}`,
|
||||||
|
'',
|
||||||
|
fassung.text,
|
||||||
|
].join('\n')
|
||||||
|
: werte.text
|
||||||
|
const pdf = await briefPdfErzeugen(ablageText, versandAm)
|
||||||
|
const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf')
|
||||||
|
const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf')
|
||||||
|
|
||||||
|
// --- Dann der Datensatz -------------------------------------------------
|
||||||
|
const dokument = await db.dokument.create({
|
||||||
|
data: {
|
||||||
|
betreuteId,
|
||||||
|
titel: (istEmail ? `E-Mail: ${werte.betreff}` : werte.betreff).slice(0, 200),
|
||||||
|
art: 'SCHREIBEN',
|
||||||
|
richtung: 'AUSGANG',
|
||||||
|
schreibenVom: versandAm,
|
||||||
|
versandAm,
|
||||||
|
empfaenger: werte.empfaengerName || null,
|
||||||
|
objektSchluessel: ablage.schluessel,
|
||||||
|
dateiname: ablage.dateiname,
|
||||||
|
mimeTyp: ablage.typ.mimeTyp,
|
||||||
|
groesseBytes: ablage.groesseBytes,
|
||||||
|
pruefsumme: ablage.pruefsumme,
|
||||||
|
// Was wir selbst gesetzt haben, muss nicht erkannt werden.
|
||||||
|
ocrStatus: 'nicht_verfuegbar',
|
||||||
|
// Ein eigenes Schreiben gehoert nicht in den Posteingang - es ist
|
||||||
|
// ja nichts angekommen, das noch zuzuordnen waere.
|
||||||
|
posteingangStatus: 'ZUGEORDNET',
|
||||||
|
...(vertrag || vorgang
|
||||||
|
? {
|
||||||
|
verknuepfungen: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
betreuteId,
|
||||||
|
...(vertrag ? { vertragId: vertrag.id } : {}),
|
||||||
|
...(vorgang ? { vorgangId: vorgang.id } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const meldung = [`Als Ausgangsdokument abgelegt: „${dokument.titel}“.`]
|
||||||
|
|
||||||
|
// --- Wiedervorlage ------------------------------------------------------
|
||||||
|
const wiedervorlage = tagAusText(werte.wiedervorlageAm)
|
||||||
|
if (wiedervorlage) {
|
||||||
|
const frist = await db.frist.create({
|
||||||
|
data: {
|
||||||
|
betreuteId,
|
||||||
|
vorgangId: vorgang?.id ?? null,
|
||||||
|
titel: `Antwort abwarten: ${werte.betreff}`.slice(0, 200),
|
||||||
|
beschreibung:
|
||||||
|
`Am ${versandAm.toLocaleDateString('de-DE')} verschickt` +
|
||||||
|
(werte.empfaengerName ? ` an ${werte.empfaengerName}` : '') +
|
||||||
|
'. Kommt bis zu diesem Tag nichts, nachfassen.',
|
||||||
|
datum: wiedervorlage,
|
||||||
|
typ: 'WIEDERVORLAGE',
|
||||||
|
verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
meldung.push(
|
||||||
|
`Wiedervorlage zum ${wiedervorlage.toLocaleDateString('de-DE')} angelegt.`,
|
||||||
|
)
|
||||||
|
void frist
|
||||||
|
} else if (SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage !== null) {
|
||||||
|
meldung.push('Ohne Wiedervorlage abgelegt — es erinnert dich also nichts daran.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Beim Vorgang festhalten, dass wir gerade geschrieben haben --------
|
||||||
|
if (vorgang) {
|
||||||
|
await db.vorgang.update({
|
||||||
|
where: { id: vorgang.id },
|
||||||
|
data: { letzterKontaktAm: versandAm },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'Dokument',
|
||||||
|
entitaetId: dokument.id,
|
||||||
|
bezeichnung: `Schreiben ausgegangen: ${dokument.titel}`,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/dokumente')
|
||||||
|
revalidatePath('/fristen')
|
||||||
|
revalidatePath('/heute')
|
||||||
|
if (vertrag) revalidatePath(`/vertraege/${vertrag.id}`)
|
||||||
|
if (vorgang) revalidatePath(`/vorgaenge/${vorgang.id}`)
|
||||||
|
|
||||||
|
return { erfolg: 'Das Schreiben liegt in der Akte.', meldung }
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
|
||||||
|
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kontaktSchema = z.object({
|
||||||
|
betreuteId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1, 'Bitte einen Namen eintragen.').max(200),
|
||||||
|
anschrift: z.string().trim().max(500).default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt aus einer frei eingegebenen Anschrift einen Kontakt an.
|
||||||
|
*
|
||||||
|
* Damit man dieselbe Stelle beim naechsten Mal aus der Liste waehlen kann,
|
||||||
|
* statt sie erneut abzutippen. Genau so waechst das Adressbuch einer
|
||||||
|
* Betreuung: nicht durch Pflege, sondern nebenbei beim Schreiben.
|
||||||
|
*/
|
||||||
|
export async function kontaktAusAnschrift(
|
||||||
|
_zustand: SchreibenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<SchreibenZustand> {
|
||||||
|
const gelesen = kontaktSchema.safeParse({
|
||||||
|
betreuteId: daten.get('betreuteId'),
|
||||||
|
name: daten.get('empfaengerName'),
|
||||||
|
anschrift: daten.get('empfaengerAnschrift') ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { betreuteId, name, anschrift } = gelesen.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
|
||||||
|
|
||||||
|
// Die freie Anschrift kommt als Block; die erste Zeile ist die Strasse,
|
||||||
|
// die zweite PLZ und Ort. Mehr wird nicht geraten - was nicht sicher
|
||||||
|
// zuzuordnen ist, landet in den Notizen.
|
||||||
|
const zeilen = anschrift
|
||||||
|
.split('\n')
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const strasse = zeilen[0] ?? null
|
||||||
|
const ortszeile = zeilen[1] ?? ''
|
||||||
|
const treffer = ortszeile.match(/^(\d{5})\s+(.+)$/)
|
||||||
|
|
||||||
|
const kontakt = await db.kontakt.create({
|
||||||
|
data: {
|
||||||
|
betreuteId,
|
||||||
|
name: name.slice(0, 200),
|
||||||
|
organisation: name.slice(0, 200),
|
||||||
|
rolle: 'SONSTIGES',
|
||||||
|
strasse,
|
||||||
|
plz: treffer?.[1] ?? null,
|
||||||
|
ort: treffer?.[2] ?? (treffer ? null : ortszeile || null),
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'Kontakt',
|
||||||
|
entitaetId: kontakt.id,
|
||||||
|
bezeichnung: kontakt.name,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/kontakte')
|
||||||
|
return { erfolg: `„${kontakt.name}“ steht jetzt in den Kontakten.` }
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[schreiben] Kontakt konnte nicht angelegt werden:', ausnahme)
|
||||||
|
return { fehler: 'Der Kontakt konnte nicht angelegt werden.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,8 +145,13 @@ export async function vertragLoeschen(betreuteId: string, vertragId: string): Pr
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) {
|
async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) {
|
||||||
|
// Gesucht wird ueber die Verknuepfung, nicht ueber die Art. Sonst
|
||||||
|
// entsteht neben einem von Hand als "Sonstiges" angelegten Posten ein
|
||||||
|
// zweiter - und derselbe Vertrag haette zwei Wertreihen, von denen der
|
||||||
|
// Bericht die eine und der Vertrag die andere zeigt.
|
||||||
const vorhanden = await db.vermoegensposten.findFirst({
|
const vorhanden = await db.vermoegensposten.findFirst({
|
||||||
where: { betreuteId, vertragId, art: 'VERSICHERUNG', ...nichtGeloescht },
|
where: { betreuteId, vertragId, ...nichtGeloescht },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
})
|
})
|
||||||
if (vorhanden) return vorhanden
|
if (vorhanden) return vorhanden
|
||||||
return db.vermoegensposten.create({
|
return db.vermoegensposten.create({
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { redirect } from 'next/navigation'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { erfordereZugang } from '@/lib/auth/sitzung'
|
import { erfordereZugang } from '@/lib/auth/sitzung'
|
||||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
import { aenderungErmitteln, protokollieren, type Aenderung } from '@/lib/audit'
|
||||||
|
import { verweisePruefen } from '@/lib/auth/verweise'
|
||||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
||||||
import {
|
import {
|
||||||
AufgabeAbhakenSchema,
|
AufgabeAbhakenSchema,
|
||||||
@@ -58,6 +59,17 @@ function pfadeAuffrischen(vorgangId?: string): void {
|
|||||||
// Anlegen
|
// Anlegen
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Der Name eines Kontakts fuers Protokoll - null bleibt null. */
|
||||||
|
async function kontaktnameZu(kontaktId: string | null): Promise<string | null> {
|
||||||
|
if (!kontaktId) return null
|
||||||
|
const kontakt = await db.kontakt.findUnique({
|
||||||
|
where: { id: kontaktId },
|
||||||
|
select: { name: true, organisation: true },
|
||||||
|
})
|
||||||
|
if (!kontakt) return null
|
||||||
|
return [kontakt.name, kontakt.organisation].filter(Boolean).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
export async function vorgangAnlegen(
|
export async function vorgangAnlegen(
|
||||||
_vorher: Formularzustand,
|
_vorher: Formularzustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
@@ -79,6 +91,15 @@ export async function vorgangAnlegen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eingabe = gelesen.data
|
const eingabe = gelesen.data
|
||||||
|
|
||||||
|
// 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()
|
const jetzt = new Date()
|
||||||
|
|
||||||
// Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles
|
// Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles
|
||||||
@@ -101,6 +122,7 @@ export async function vorgangAnlegen(
|
|||||||
prioritaet: eingabe.prioritaet,
|
prioritaet: eingabe.prioritaet,
|
||||||
gegenstelle: eingabe.gegenstelle,
|
gegenstelle: eingabe.gegenstelle,
|
||||||
aktenzeichen: eingabe.aktenzeichen,
|
aktenzeichen: eingabe.aktenzeichen,
|
||||||
|
ansprechpartnerId: eingabe.ansprechpartnerId,
|
||||||
verantwortlich: eingabe.verantwortlich ?? benutzer.name,
|
verantwortlich: eingabe.verantwortlich ?? benutzer.name,
|
||||||
naechsterSchritt:
|
naechsterSchritt:
|
||||||
eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null,
|
eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null,
|
||||||
@@ -205,6 +227,14 @@ export async function vorgangAendern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eingabe = gelesen.data
|
const eingabe = gelesen.data
|
||||||
|
|
||||||
|
const verweisfehler = await verweisePruefen(vorgang.betreuteId, {
|
||||||
|
kontakt: eingabe.ansprechpartnerId,
|
||||||
|
})
|
||||||
|
if (verweisfehler) {
|
||||||
|
return { fehler: verweisfehler, felder: { ansprechpartnerId: verweisfehler } }
|
||||||
|
}
|
||||||
|
|
||||||
const vorherOffen = oeffnen('Vorgang', vorgang)
|
const vorherOffen = oeffnen('Vorgang', vorgang)
|
||||||
|
|
||||||
const daten: Prisma.VorgangUpdateInput = {
|
const daten: Prisma.VorgangUpdateInput = {
|
||||||
@@ -214,6 +244,11 @@ export async function vorgangAendern(
|
|||||||
prioritaet: eingabe.prioritaet,
|
prioritaet: eingabe.prioritaet,
|
||||||
gegenstelle: eingabe.gegenstelle,
|
gegenstelle: eingabe.gegenstelle,
|
||||||
aktenzeichen: eingabe.aktenzeichen,
|
aktenzeichen: eingabe.aktenzeichen,
|
||||||
|
// Ueber die Beziehung, nicht ueber das Feld: connect/disconnect ist
|
||||||
|
// das, was Prisma bei einer Update-Eingabe erwartet.
|
||||||
|
ansprechpartner: eingabe.ansprechpartnerId
|
||||||
|
? { connect: { id: eingabe.ansprechpartnerId } }
|
||||||
|
: { disconnect: true },
|
||||||
verantwortlich: eingabe.verantwortlich,
|
verantwortlich: eingabe.verantwortlich,
|
||||||
naechsterSchritt: eingabe.naechsterSchritt,
|
naechsterSchritt: eingabe.naechsterSchritt,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
@@ -227,6 +262,19 @@ export async function vorgangAendern(
|
|||||||
|
|
||||||
const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten })
|
const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten })
|
||||||
|
|
||||||
|
// Der Wechsel des Ansprechpartners gehoert mit NAMEN ins Protokoll.
|
||||||
|
// Zwei cuids nebeneinander sind protokolliert, aber nicht
|
||||||
|
// nachvollziehbar - und nachvollziehbar war die Anforderung.
|
||||||
|
const ansprechpartnerWechsel: Aenderung =
|
||||||
|
vorgang.ansprechpartnerId !== geaendert.ansprechpartnerId
|
||||||
|
? {
|
||||||
|
ansprechpartner: {
|
||||||
|
alt: await kontaktnameZu(vorgang.ansprechpartnerId),
|
||||||
|
neu: await kontaktnameZu(geaendert.ansprechpartnerId),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'GEAENDERT',
|
aktion: 'GEAENDERT',
|
||||||
entitaet: 'Vorgang',
|
entitaet: 'Vorgang',
|
||||||
@@ -238,6 +286,7 @@ export async function vorgangAendern(
|
|||||||
userName: kontext.benutzer.name,
|
userName: kontext.benutzer.name,
|
||||||
aenderung: {
|
aenderung: {
|
||||||
...aenderungErmitteln(vorgang, geaendert),
|
...aenderungErmitteln(vorgang, geaendert),
|
||||||
|
...ansprechpartnerWechsel,
|
||||||
...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null)
|
...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null)
|
||||||
? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } }
|
? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { protokollieren, aenderungErmitteln } from '@/lib/audit'
|
||||||
|
import { bestellungsabsatzFehlt } from '@/lib/schreiben/platzhalter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions fuer eigene Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Die Warnung beim fehlenden Bestellungsabsatz ist zweistufig: Der erste
|
||||||
|
* Speicherversuch ohne ihn kommt mit einer Warnung zurueck; erst die
|
||||||
|
* ausdrueckliche Bestaetigung ("ohneBestellungsabsatz") speichert. So
|
||||||
|
* laesst sich der Absatz weglassen - aber nur wissentlich. Ohne den
|
||||||
|
* Nachweis der Bestellung antwortet keine Versicherung, und das faellt
|
||||||
|
* erst nach vier Wochen auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VorlagenZustand = {
|
||||||
|
fehler?: string
|
||||||
|
/** Die Bestellungsabsatz-Warnung - Speichern erst nach Bestaetigung. */
|
||||||
|
warnung?: string
|
||||||
|
erfolg?: string
|
||||||
|
felder?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function zugriffsfehler(ausnahme: unknown): VorlagenZustand | null {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorlageSchema = z.object({
|
||||||
|
betreuteId: z.string().min(1),
|
||||||
|
vorlageId: z.string().trim().default(''),
|
||||||
|
name: z.string().trim().min(1, 'Bitte einen Namen vergeben.').max(120),
|
||||||
|
betreff: z.string().trim().max(200).default(''),
|
||||||
|
text: z.string().trim().min(10, 'Der Text ist zu kurz für eine Vorlage.').max(50_000),
|
||||||
|
ohneBestellungsabsatz: z.string().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function vorlageSpeichern(
|
||||||
|
_zustand: VorlagenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<VorlagenZustand> {
|
||||||
|
const gelesen = vorlageSchema.safeParse({
|
||||||
|
betreuteId: daten.get('betreuteId'),
|
||||||
|
vorlageId: daten.get('vorlageId') ?? '',
|
||||||
|
name: daten.get('name'),
|
||||||
|
betreff: daten.get('betreff') ?? '',
|
||||||
|
text: daten.get('text'),
|
||||||
|
ohneBestellungsabsatz: daten.get('ohneBestellungsabsatz') ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = gelesen.error.flatten().fieldErrors
|
||||||
|
return {
|
||||||
|
fehler: 'Bitte prüfe die Eingaben.',
|
||||||
|
felder: Object.fromEntries(
|
||||||
|
Object.entries(felder).map(([name, meldungen]) => [name, meldungen?.[0] ?? '']),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = gelesen.data
|
||||||
|
const bestaetigt = werte.ohneBestellungsabsatz === 'ja'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(werte.betreuteId, 'DOKUMENTE', 'schreiben')
|
||||||
|
|
||||||
|
// Die zweistufige Warnung: erst warnen, dann - bestaetigt - speichern.
|
||||||
|
if (bestellungsabsatzFehlt(werte.text) && !bestaetigt) {
|
||||||
|
return {
|
||||||
|
warnung:
|
||||||
|
'In dieser Vorlage fehlt der Bestellungsabsatz — der Nachweis, dass du zur Vertretung ' +
|
||||||
|
'befugt bist. Ohne ihn antworten Versicherungen und Behörden mit „aus ' +
|
||||||
|
'datenschutzrechtlichen Gründen keine Auskunft", und das fällt erst nach Wochen auf. ' +
|
||||||
|
'Füge {{bestellungsabsatz}} ein — oder speichere bewusst ohne.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (werte.vorlageId) {
|
||||||
|
const vorhanden = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: werte.vorlageId, betreuteId: werte.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!vorhanden) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
const nachher = await db.schreibvorlage.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: {
|
||||||
|
name: werte.name,
|
||||||
|
betreff: werte.betreff,
|
||||||
|
text: werte.text,
|
||||||
|
ohneBestellungsabsatz: bestaetigt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'GEAENDERT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: nachher.id,
|
||||||
|
bezeichnung: nachher.name,
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
aenderung: aenderungErmitteln(
|
||||||
|
{ name: vorhanden.name, betreff: vorhanden.betreff, text: vorhanden.text },
|
||||||
|
{ name: nachher.name, betreff: nachher.betreff, text: nachher.text },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const neu = await db.schreibvorlage.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
name: werte.name,
|
||||||
|
betreff: werte.betreff,
|
||||||
|
text: werte.text,
|
||||||
|
ohneBestellungsabsatz: bestaetigt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: neu.id,
|
||||||
|
bezeichnung: neu.name,
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[vorlagen] Speichern fehlgeschlagen:', ausnahme)
|
||||||
|
return { fehler: 'Die Vorlage konnte nicht gespeichert werden.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen/vorlagen')
|
||||||
|
return { erfolg: `„${werte.name}“ ist gespeichert.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Papierkorb: geloeschtAm setzen, 30 Tage rueckholbar wie ueberall. */
|
||||||
|
export async function vorlageLoeschen(
|
||||||
|
_zustand: VorlagenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<VorlagenZustand> {
|
||||||
|
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||||
|
const vorlageId = String(daten.get('vorlageId') ?? '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
|
||||||
|
|
||||||
|
const vorlage = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: vorlageId, betreuteId, ...nichtGeloescht },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!vorlage) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
await db.schreibvorlage.update({
|
||||||
|
where: { id: vorlage.id },
|
||||||
|
data: { geloeschtAm: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'GELOESCHT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: vorlage.id,
|
||||||
|
bezeichnung: vorlage.name,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[vorlagen] Löschen fehlgeschlagen:', ausnahme)
|
||||||
|
return { fehler: 'Die Vorlage konnte nicht gelöscht werden.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen/vorlagen')
|
||||||
|
return { erfolg: 'In den Papierkorb gelegt — 30 Tage rückholbar.' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ALLGEMEINE_ANREDE, anredeZeile, nachnameAus } from '@/lib/kontakte/anrede'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Briefanrede.
|
||||||
|
*
|
||||||
|
* Zwei Zusagen werden hier gepruft: Es wird nie geraten, und ein halb
|
||||||
|
* gefuellter Name fuehrt nie zu einer verstuemmelten Anrede.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Nachname', () => {
|
||||||
|
it('nimmt das letzte Wort', () => {
|
||||||
|
expect(nachnameAus('Anna Schmitt')).toBe('Schmitt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt Titel und Vornamen weg', () => {
|
||||||
|
expect(nachnameAus('Dr. Anna Maria Schmitt')).toBe('Schmitt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält einen Doppelnamen zusammen', () => {
|
||||||
|
expect(nachnameAus('Anna Schmitt-Berger')).toBe('Schmitt-Berger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verträgt überzählige Leerzeichen', () => {
|
||||||
|
expect(nachnameAus(' Anna Schmitt ')).toBe('Schmitt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Anredezeile', () => {
|
||||||
|
it('redet eine Frau mit Nachnamen an', () => {
|
||||||
|
expect(anredeZeile({ name: 'Anna Schmitt', anrede: 'FRAU' })).toBe(
|
||||||
|
'Sehr geehrte Frau Schmitt,',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redet einen Herrn mit Nachnamen an', () => {
|
||||||
|
expect(anredeZeile({ name: 'Peter Vogel', anrede: 'HERR' })).toBe(
|
||||||
|
'Sehr geehrter Herr Vogel,',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rät nicht, wenn die Anrede fehlt', () => {
|
||||||
|
// Lieber die etwas steife Form als ein falsch geratenes "Frau".
|
||||||
|
expect(anredeZeile({ name: 'Kim Berger', anrede: 'OHNE' })).toBe(
|
||||||
|
'Sehr geehrte(r) Kim Berger,',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bleibt ohne Ansprechpartner allgemein', () => {
|
||||||
|
expect(anredeZeile(null)).toBe(ALLGEMEINE_ANREDE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wird bei leerem Namen nicht verstümmelt', () => {
|
||||||
|
// Der Fehler, der hier nie passieren darf: "Sehr geehrte Frau ,".
|
||||||
|
expect(anredeZeile({ name: ' ', anrede: 'FRAU' })).toBe(ALLGEMEINE_ANREDE)
|
||||||
|
})
|
||||||
|
})
|
||||||
+665
-665
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user