Compare commits

..
8 Commits
Author SHA1 Message Date
florian.wilde 57ce73dfbc Merge #18: Eigene Vorlagen fuer Anschreiben
Behebt Issue #7: eigene Schreibvorlagen unter Einstellungen, Platzhalter
mit sichtbaren Luecken, zweistufige Bestellungsabsatz-Warnung, vier
mitgelieferte Vorlagen als Kopiervorlage, Auswahl beim freien Schreiben.

Closes #7
2026-08-23 11:59:44 +00:00
Florian Wilde f8d7752c27 Eigene Vorlagen fuer Anschreiben
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.

Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
  Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
  [Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
  Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
  er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
  ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
  speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
  und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
  zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
  Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
  "Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
  und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
  fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
  Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
  beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
  sind im Vollexport ihrer Akte enthalten.

Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.

Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.

Refs #7
2026-08-23 13:41:51 +02:00
florian.wilde 0a8878d8d2 Merge #17: Rueckkaufswerte in einem Zug anfragen
Behebt Issue #6: Ein Klick statt fuenf bis zehn einzelner Briefe - ZIP
je Gesellschaft, PDF in der Akte, Wiedervorlage, Anfragevermerk.

Closes #6
2026-08-23 10:18:02 +00:00
Florian Wilde 9fe6f10f25 Rueckkaufswerte aller Versicherungen in einem Zug anfragen
Vor Vermoegensverzeichnis und Jahresbericht braucht es von jeder Lebens-
und Rentenversicherung den Rueckkaufswert zum Stichtag - jedes Mal
dieselben fuenf bis zehn Briefe. Neu unter Vertraege: Stichtag waehlen,
Liste pruefen, ein Klick. Heraus kommt ein ZIP mit einer Word-Datei je
Gesellschaft plus Begleitblatt; je Vertrag entstehen nebenbei die
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
Anfragevermerk.

Aufbau:
- Auswahllogik als reine Funktionen in vertraege/serienabfrage.ts:
  Vorauswahl, Hindernisse mit Begruendung statt stummem Ausblenden,
  28-Tage-Sperre im Gleichschritt mit der Wiedervorlage.
- Der Lauf in vertraege/serienlauf.ts: je Vertrag das volle Paket aus
  Issue #4, aber in einem Durchgang. Kein Alles-oder-nichts - ein
  gescheiterter Vertrag steht mit Grund im Begleitblatt, die uebrigen
  laufen durch. Eine Transaktion ueber alle waere die falsche Zusage.
- Dublettenschutz tagesgenau: Der Doppelklick erzeugt nichts Doppeltes,
  die bewusste zweite Anfrage nach einer Woche bleibt moeglich.
- Die Anzahl steht vor dem Klick auf dem Knopf.

Gekuendigte Vertraege bleiben anfragbar: Bis zum Wirksamwerden besteht
der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.

Geprueft: zwoelf Einheitstests, drei End-to-End-Tests inklusive der
Nebenwirkungen in der Datenbank und des Dublettenschutzes. 602
Unit-Tests, 87 e2e, null Typfehler.

Refs #6
2026-08-23 12:10:32 +02:00
florian.wilde 96a9bc4d63 Merge #16: Schreiben als E-Mail uebernehmen
Behebt Issue #5: Umschalter Brief/E-Mail im Schreiben-Editor, mailto
fuer kurze Texte, .eml immer, Ablage als gekennzeichnete E-Mail.

Closes #5
2026-08-23 09:18:59 +00:00
Florian Wilde b59d11c9b4 Schreiben als E-Mail uebernehmen
Im Schreiben-Editor gibt es jetzt den Umschalter Brief/E-Mail. Die
E-Mail-Fassung wird aus dem - womoeglich bearbeiteten - Brieftext
abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, aber mit dem
Bestellungsabsatz. Betreff und Text einzeln kopierbar.

Die offene Entscheidung aus dem Issue (mailto oder .eml) ist nach
Rueckfrage mit BEIDES beantwortet:

- mailto fuer kurze Texte. Der Knopf verschwindet, sobald der kodierte
  Link 1800 Zeichen uebersteigt - mailto uebergibt den Text in der
  Adresszeile, deren Laenge nirgends zugesichert ist, und ein halbierter
  Brief faellt erst beim Empfaenger auf. Die Grenze wird NACH der
  URL-Kodierung gerechnet; 900 Umlaute sind roh harmlos und kodiert
  weit drueber.
- .eml immer: RFC 5322 mit X-Unsent: 1, quoted-printable fuer den Rumpf,
  RFC-2047-Kodierung fuer Umlaute im Betreff. Bewusst ohne From-Kopf -
  ein erfundener Absender wuerde je nach Programm uebernommen.

Beide Knoepfe erscheinen nur mit E-Mail-Adresse (aus dem Kontakt
uebernommen oder von Hand). Ohne Adresse bleibt das Kopieren.

Die Ablage kennt den Unterschied: Eine E-Mail heisst in der Akte
"E-Mail: ...", und abgelegt wird die E-Mail-Fassung samt
Empfaengeradresse im Kopf der PDF - in der Akte soll stehen, was
tatsaechlich hinausging.

Sicherung in emailFassung(): Findet sich keine Anrede (frei umgebauter
Text), wird NICHTS entfernt. Lieber ein Briefkopf zu viel in der Mail
als stillschweigend verlorener Inhalt.

Geprueft: 20 Einheitstests, vier End-to-End-Tests auf beiden
Geraeteprofilen. 590 Unit-Tests, 81 e2e, null Typfehler.

Refs #5
2026-08-23 11:02:00 +02:00
florian.wilde a52523fc97 Merge #15: Schreiben erzeugen, ausgeben und ablegen
Behebt Issue #4: Ein Schreiben geht jetzt den ganzen Weg - bearbeiten,
Word-Datei, Ablage als Ausgangsdokument, Wiedervorlage. Auch an Stellen,
zu denen es noch keinen Datensatz gibt.

Closes #4
2026-08-23 08:18:38 +00:00
Florian Wilde c21d03abf6 Schreiben erzeugen, ausgeben und ablegen
Die Anschreiben 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. Der letzte Schritt wurde am
haeufigsten vergessen, und dann fehlt der Nachweis im Bericht.

Neu unter /schreiben/neu: bearbeiten, als Word-Datei herunterladen, in
die Akte legen, Wiedervorlage setzen - in einem Zug. Eine Seite fuer alle
Arten und alle drei Empfaengerwege, statt die Bearbeitung dreimal an
Vertrag, Vorgang und Freitext nachzubauen.

Der dritte Weg ist der eigentliche Zugewinn: Schreiben an eine Stelle,
zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung ist das
der Normalfall - man fragt ja an, um zu erfahren, ob dort ueberhaupt
etwas besteht. Aus der freien Anschrift laesst sich mit einem Klick ein
Kontakt anlegen.

Zwei Entscheidungen, die erklaert gehoeren:

Die Akte bekommt ein PDF, heruntergeladen wird .docx. Der
Objektspeicher nimmt nur PDF und Bilder an, geprueft an den Magic Bytes.
Eine .docx ist ein ZIP-Archiv; sie durchzulassen hiesse jedes ZIP
durchzulassen, und diese Pruefung ist eine bewusste Zusage der
Anwendung. Sachlich ist PDF fuer den Nachweis ohnehin richtiger: Es
aendert sich nicht mehr. Die Oberflaeche sagt dazu, dass eine spaeter in
Word geaenderte Fassung von der Akte abweicht.

Der Berichtssatz in berichte/docx.ts bleibt unangetastet. Er setzt einen
Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief braucht
Absender, Anschriftenfeld, Datumszeile und Grussformel. Beides in eine
Funktion zu zwingen haette beide Ausgaben schlechter gemacht. Der
Umsetzungshinweis im Issue ("muss nur angebunden werden") war insoweit
falsch - von mir selbst geschrieben.

Die vorhandenen Erzeuger (Rueckkaufswert, Kuendigung, Sachstandsanfrage)
bleiben unveraendert; ihr Wortlaut ist getestet und erprobt.

Geprueft: zehn Einheitstests, darunter der Gleichschritt der
Wiedervorlagefristen ueber zwei Module hinweg. Fuenf End-to-End-Tests
auf beiden Geraeteprofilen: Entwurf, Vertragsbezug, Word-Download,
Ablage samt Wiedervorlage, Kontakt aus freier Anschrift.

Refs #4
2026-08-23 10:14:59 +02:00
46 changed files with 5787 additions and 632 deletions
+233 -1
View File
@@ -26,6 +26,234 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [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 +928,11 @@ 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.7.0...HEAD
[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
+4 -4
View File
@@ -580,11 +580,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
+7 -7
View File
@@ -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.7.0
``` ```
### 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.7.0
``` ```
--- ---
@@ -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.7.0","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.7.0",...}
``` ```
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.7.0 · <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.7.0` | 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.7.0
# 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
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb 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.7.0** ([CHANGELOG](../CHANGELOG.md))
--- ---
+123
View File
@@ -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')
})
})
+179
View File
@@ -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')
})
})
+185
View File
@@ -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)
})
})
+157
View File
@@ -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()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.3.0", "version": "1.7.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
@@ -0,0 +1,27 @@
-- Eigene Schreibvorlagen.
--
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- 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;
+34
View File
@@ -313,6 +313,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[]
@@ -842,6 +843,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())
+18 -1
View File
@@ -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>
)
}
+68
View File
@@ -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',
},
})
}
+64
View File
@@ -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',
},
})
}
+148
View File
@@ -0,0 +1,148 @@
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,
benutzerName: benutzer.name,
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,
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>
)
}
+15 -7
View File
@@ -1,5 +1,5 @@
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'
@@ -114,12 +114,20 @@ export default async function VertraegeUebersicht() {
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]} pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
aktionen={ aktionen={
schreiben && ( schreiben && (
<Button asChild> <>
<Link href="/vertraege/neu"> <Button asChild variant="outline">
<Plus aria-hidden="true" /> <Link href="/vertraege/serienabfrage/seite">
Vertrag anlegen <FileArchive aria-hidden="true" />
</Link> Rückkaufswerte anfragen
</Button> </Link>
</Button>
<Button asChild>
<Link href="/vertraege/neu">
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
</>
) )
} }
/> />
@@ -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>
)
}
+9 -1
View File
@@ -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'
@@ -245,6 +245,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} />}
</> </>
} }
@@ -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>
)
}
@@ -0,0 +1,513 @@
'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; 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('')
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,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>
+11 -1
View File
@@ -1,7 +1,8 @@
'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 { 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'
@@ -149,6 +150,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 &amp; 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>
)
}
+7
View File
@@ -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',
+113
View File
@@ -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}`
}
+118
View File
@@ -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))
}
+137
View File
@@ -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())
}
+95
View File
@@ -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')
}
+303
View File
@@ -0,0 +1,303 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } 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
benutzerName: string
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: { name: eingabe.benutzerName },
})
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 },
include: {
kontakte: { select: { name: true, organisation: true }, take: 1 },
},
})
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.benutzerName,
aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht,
},
heute,
})
return {
...gemeinsam,
text: anfrage.text,
betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '',
empfaengerAnschrift: '',
}
}
// --- 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,
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: '',
}
}
+123
View File
@@ -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'),
},
]
+104
View File
@@ -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} />)
}
+143
View File
@@ -0,0 +1,143 @@
/**
* 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'
| '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: '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
}
+1 -1
View File
@@ -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.7.0'
/** /**
* Kurzer Git-Stand, beim Bauen hineingereicht: * Kurzer Git-Stand, beim Bauen hineingereicht:
+139
View File
@@ -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
}
+254
View File
@@ -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 } 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 }
}): 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: { name: benutzer.name },
})
// 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
View File
@@ -50,6 +50,7 @@ const PAPIERKORB_TABELLEN = [
'bescheid', 'bescheid',
'leistung', 'leistung',
'bericht', 'bericht',
'schreibvorlage',
'betreute', 'betreute',
] as const ] as const
+8
View File
@@ -105,6 +105,14 @@ function eintragsarten(): Record<string, Eintragsart> {
), ),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'), Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'), Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach(
'Schreibvorlage',
'Vorlage',
'Vorlagen',
'DOKUMENTE',
z.schreibvorlage,
'name',
),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'), Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach( Kontaktprotokoll: einfach(
'Kontaktprotokoll', 'Kontaktprotokoll',
+310
View File
@@ -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.' }
}
}
+187
View File
@@ -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.' }
}
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
MAILTO_HOECHSTLAENGE,
emailFassung,
mailtoLink,
siehtAusWieEmail,
} from '@/lib/schreiben/email'
import { emlErzeugen, kopfzeileKodieren, quotedPrintable } from '@/lib/schreiben/eml'
/**
* Schreiben als E-Mail.
*
* Zwei Gefahren stehen im Mittelpunkt: dass beim Umbau vom Brief zur Mail
* stillschweigend Inhalt verloren geht (vor allem der Bestellungsabsatz),
* und dass ein mailto-Link einen halben Brief übergibt, ohne dass es
* jemand merkt.
*/
const BRIEF = [
'Florian Wilde',
'Musterstraße 1',
'96450 Coburg',
'',
'Allianz Lebensversicherungs-AG',
'[Straße und Hausnummer]',
'[PLZ und Ort]',
'',
'23.08.2026',
'',
'Betreff: Auskunft über Rückkaufswert — Franziska Wilde, geb. 23.09.1963',
'',
'Sehr geehrte Damen und Herren,',
'',
'ich bin durch Beschluss des Amtsgerichts Lichtenfels (Az. XVII 355/25) ' +
'zum rechtlichen Betreuer bestellt.',
'',
'für ein laufendes Verfahren benötige ich folgende Auskünfte.',
'',
'Mit freundlichen Grüßen',
'Florian Wilde',
].join('\n')
describe('E-Mail-Fassung aus dem Brief', () => {
it('entfernt Absender, Anschrift, Datum und Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('Musterstraße')
expect(fassung.text).not.toContain('[Straße und Hausnummer]')
expect(fassung.text).not.toContain('23.08.2026')
expect(fassung.text).not.toContain('Betreff:')
})
it('beginnt mit der Anrede und behält den Bestellungsabsatz', () => {
// Der Absatz ist der Grund, warum überhaupt geantwortet wird.
const fassung = emailFassung(BRIEF)
expect(fassung.text.startsWith('Sehr geehrte Damen und Herren,')).toBe(true)
expect(fassung.text).toContain('Amtsgerichts Lichtenfels')
expect(fassung.text).toContain('Mit freundlichen Grüßen')
})
it('zieht den Betreff aus der Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.betreff).toContain('Auskunft über Rückkaufswert')
expect(fassung.betreff).toContain('Franziska Wilde')
})
it('lässt einen frei umgebauten Text unangetastet', () => {
// Ohne erkennbare Anrede wird nichts entfernt: Lieber ein Briefkopf zu
// viel in der Mail als stillschweigend verlorener Inhalt.
const frei = 'Hallo Frau Maier,\nhier die gewünschten Unterlagen.\nViele Grüße'
const fassung = emailFassung(frei, 'Unterlagen')
expect(fassung.text).toContain('Hallo Frau Maier,')
expect(fassung.text).toContain('Viele Grüße')
expect(fassung.betreff).toBe('Unterlagen')
})
it('faltet mehrfache Leerzeilen zusammen', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('\n\n\n')
})
})
describe('mailto-Link', () => {
const fassung = { betreff: 'Kurzer Betreff', text: 'Kurzer Text.' }
it('baut den Link mit Adresse, Betreff und Text', () => {
const link = mailtoLink('service@allianz.de', fassung)
expect(link).not.toBeNull()
expect(link).toContain('mailto:service%40allianz.de')
expect(link).toContain('subject=Kurzer%20Betreff')
expect(link).toContain('body=Kurzer%20Text.')
})
it('verweigert den Link bei zu langem Text, statt abzuschneiden', () => {
// Der Kern der Beides-Entscheidung: Ein Knopf, der einen halben Brief
// übergibt, wäre schlimmer als keiner.
const lang = { betreff: 'B', text: 'ä'.repeat(MAILTO_HOECHSTLAENGE) }
expect(mailtoLink('a@b.de', lang)).toBeNull()
})
it('verweigert den Link ohne Adresse', () => {
expect(mailtoLink('', fassung)).toBeNull()
expect(mailtoLink(' ', fassung)).toBeNull()
})
it('rechnet die Grenze nach der Kodierung, nicht davor', () => {
// 900 Umlaute sind roh unter der Grenze, kodiert (je 3 Byte -> %C3%A4,
// 6 Zeichen) weit darüber.
const umlaute = { betreff: 'B', text: 'ä'.repeat(900) }
expect(mailtoLink('a@b.de', umlaute)).toBeNull()
})
})
describe('Adressprüfung', () => {
it('erkennt gewöhnliche Adressen', () => {
expect(siehtAusWieEmail('service@allianz.de')).toBe(true)
expect(siehtAusWieEmail('info@stadt-coburg.bayern.de')).toBe(true)
})
it('weist Offensichtliches ab', () => {
expect(siehtAusWieEmail('')).toBe(false)
expect(siehtAusWieEmail(null)).toBe(false)
expect(siehtAusWieEmail('kein-at-zeichen.de')).toBe(false)
expect(siehtAusWieEmail('zwei @ leerzeichen@x.de')).toBe(false)
})
})
describe('.eml-Datei', () => {
it('trägt Empfänger, Betreff und den vollen Text', () => {
const eml = emlErzeugen({
an: 'service@allianz.de',
betreff: 'Testbetreff',
text: 'Zeile eins.\nZeile zwei.',
}).toString('utf8')
expect(eml).toContain('To: service@allianz.de')
expect(eml).toContain('Subject: Testbetreff')
expect(eml).toContain('Zeile eins.')
expect(eml).toContain('Zeile zwei.')
})
it('bittet das Mailprogramm um einen Entwurf', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).toContain('X-Unsent: 1')
})
it('erfindet keinen Absender', () => {
// Ein erfundener From-Kopf würde je nach Programm übernommen - und
// dann ginge die Mail mit falschem Absender hinaus.
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).not.toMatch(/^From:/m)
})
it('kodiert Umlaute im Betreff nach RFC 2047', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Rückkaufswert', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: =?UTF-8?B?')
// Und zurückgerechnet stimmt der Inhalt.
const treffer = eml.match(/Subject: =\?UTF-8\?B\?([A-Za-z0-9+/=]+)\?=/)
expect(Buffer.from(treffer![1]!, 'base64').toString('utf8')).toBe('Rückkaufswert')
})
it('lässt einen ASCII-Betreff lesbar', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Anfrage', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: Anfrage')
})
})
describe('quoted-printable', () => {
it('kodiert Umlaute und lässt ASCII lesbar', () => {
const kodiert = quotedPrintable('Grüße')
expect(kodiert).toContain('Gr')
expect(kodiert).toContain('=C3=BC')
expect(kodiert).toContain('=C3=9F')
})
it('kodiert das Gleichheitszeichen', () => {
expect(quotedPrintable('a=b')).toBe('a=3Db')
})
it('hält die 76-Zeichen-Grenze je Zeile ein', () => {
const kodiert = quotedPrintable('ä'.repeat(200))
for (const zeile of kodiert.split('\r\n')) {
expect(zeile.length).toBeLessThanOrEqual(76)
}
})
it('lässt Kopfzeilen ohne Sonderzeichen unkodiert', () => {
expect(kopfzeileKodieren('Anfrage 2026')).toBe('Anfrage 2026')
})
})
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
PLATZHALTER,
benutztePlatzhalter,
bestellungsabsatzFehlt,
platzhalterErsetzen,
} from '@/lib/schreiben/platzhalter'
/**
* Platzhalter in Schreibvorlagen.
*
* Der Grundsatz, den jede Prüfung hier absichert: NICHTS geht
* stillschweigend schief. Fehlende Daten werden zur sichtbaren Lücke,
* unbekannte Platzhalter zur Markierung — nie zu einer leeren Stelle, die
* erst der Empfänger bemerkt.
*/
describe('Ersetzen', () => {
it('setzt bekannte Werte ein', () => {
const ergebnis = platzhalterErsetzen('Sehr geehrte Damen und Herren, {{betreuer_name}} hier.', {
betreuer_name: 'Florian Wilde',
})
expect(ergebnis.text).toBe('Sehr geehrte Damen und Herren, Florian Wilde hier.')
expect(ergebnis.luecken).toHaveLength(0)
expect(ergebnis.unbekannte).toHaveLength(0)
})
it('macht aus fehlenden Daten eine sichtbare Lücke', () => {
const ergebnis = platzhalterErsetzen('Az. {{aktenzeichen_gericht}}', {})
expect(ergebnis.text).toBe('Az. [Aktenzeichen des Gerichts]')
expect(ergebnis.luecken).toEqual(['aktenzeichen_gericht'])
})
it('behandelt leere Zeichenketten wie fehlende Daten', () => {
const ergebnis = platzhalterErsetzen('{{betreuungsgericht}}', { betreuungsgericht: ' ' })
expect(ergebnis.text).toContain('[Betreuungsgericht]')
})
it('markiert unbekannte Platzhalter, statt abzubrechen', () => {
// Ein Tippfehler in der Vorlage darf das Schreiben nicht verhindern —
// aber er muss ins Auge springen.
const ergebnis = platzhalterErsetzen('Hallo {{betreur_name}}!', {})
expect(ergebnis.text).toBe('Hallo [Unbekannter Platzhalter: {{betreur_name}}]!')
expect(ergebnis.unbekannte).toEqual(['betreur_name'])
})
it('verträgt Leerzeichen in den Klammern', () => {
const ergebnis = platzhalterErsetzen('{{ betreuer_name }}', { betreuer_name: 'F. Wilde' })
expect(ergebnis.text).toBe('F. Wilde')
})
it('ersetzt mehrere Vorkommen und meldet jede Lücke nur einmal', () => {
const ergebnis = platzhalterErsetzen('{{stichtag}} und nochmal {{stichtag}}', {})
expect(ergebnis.luecken).toEqual(['stichtag'])
expect(ergebnis.text.match(/\[Stichtag\]/g)).toHaveLength(2)
})
it('lässt Text ohne Platzhalter unangetastet', () => {
const text = 'Ein Brief ohne alles. Auch { einzelne } Klammern bleiben.'
expect(platzhalterErsetzen(text, {}).text).toBe(text)
})
})
describe('Platzhalterliste', () => {
it('beschreibt jeden Platzhalter mit Beschriftung und Quelle', () => {
// Die Liste steht neben dem Editor - niemand soll Namen raten müssen.
for (const platzhalter of PLATZHALTER) {
expect(platzhalter.beschriftung.length, platzhalter.name).toBeGreaterThan(3)
expect(platzhalter.quelle.length, platzhalter.name).toBeGreaterThan(3)
}
})
it('findet die benutzten Platzhalter einer Vorlage', () => {
expect(
benutztePlatzhalter('{{betreuer_name}} an {{vertrag_gesellschaft}}, nochmal {{betreuer_name}}'),
).toEqual(['betreuer_name', 'vertrag_gesellschaft'])
})
})
describe('Bestellungsabsatz-Warnung', () => {
it('ist zufrieden mit dem Platzhalter', () => {
expect(bestellungsabsatzFehlt('Text mit {{bestellungsabsatz}} darin.')).toBe(false)
})
it('ist zufrieden mit einem von Hand geschriebenen Nachweis', () => {
// Wer den Absatz ausformuliert hat, wird nicht gegängelt.
expect(
bestellungsabsatzFehlt(
'Durch Beschluss des Amtsgerichts bin ich zum Betreuer bestellt.',
),
).toBe(false)
})
it('warnt bei einer Vorlage ohne jeden Nachweis', () => {
expect(bestellungsabsatzFehlt('Sehr geehrte Damen und Herren, bitte um Auskunft.')).toBe(true)
})
it('lässt sich nicht von einem halben Nachweis täuschen', () => {
// "Beschluss" allein (etwa der Beschluss der Eigentümerversammlung)
// ist kein Bestellungsnachweis.
expect(bestellungsabsatzFehlt('Zum Beschluss der Eigentümerversammlung...')).toBe(true)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
SCHREIBEN_ARTEN,
SCHREIBEN_EIGENSCHAFTEN,
dateinameFuerSchreiben,
istSchreibenArt,
} from '@/lib/schreiben/arten'
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert'
import { NACHFASSEN_AB_TAGEN } from '@/lib/vorgaenge/sachstandsanfrage'
/**
* Schreiben erzeugen — die Teile ohne Datenbank.
*
* Der Schwerpunkt liegt auf zwei Dingen, die sonst still auseinanderlaufen:
* den Wiedervorlagefristen (sie stehen an zwei Stellen) und dem Dateinamen
* (er landet im Objektspeicher und im Download).
*/
describe('Arten von Schreiben', () => {
it('erkennt bekannte Arten und weist alles andere ab', () => {
expect(istSchreibenArt('RUECKKAUFSWERT')).toBe(true)
expect(istSchreibenArt('FREI')).toBe(true)
expect(istSchreibenArt('etwas-anderes')).toBe(false)
expect(istSchreibenArt(null)).toBe(false)
expect(istSchreibenArt(undefined)).toBe(false)
})
it('beschreibt jede Art vollständig', () => {
for (const art of SCHREIBEN_ARTEN) {
const eigenschaft = SCHREIBEN_EIGENSCHAFTEN[art]
expect(eigenschaft.beschriftung.length, art).toBeGreaterThan(3)
// Wo es eine Frist gibt, muss auch dastehen, warum sie so lang ist.
if (eigenschaft.wiedervorlageTage !== null) {
expect(eigenschaft.fristBegruendung, art).toBeTruthy()
}
}
})
it('schlägt für ein freies Schreiben KEINE Frist vor', () => {
// Bei einem freien Schreiben weiß niemand, ob und wann eine Antwort
// kommt. Eine erfundene Frist wäre schlechter als keine.
expect(SCHREIBEN_EIGENSCHAFTEN.FREI.wiedervorlageTage).toBeNull()
})
})
describe('Wiedervorlagefristen bleiben mit den Modulen im Gleichschritt', () => {
it('nimmt beim Rückkaufswert dieselbe Zahl wie das Vertragsmodul', () => {
// Sie steht dort für die Anzeige am Vertrag, hier für die erzeugte
// Frist. Laufen sie auseinander, mahnt die Anwendung zu zwei
// verschiedenen Terminen — und man glaubt keinem mehr.
expect(SCHREIBEN_EIGENSCHAFTEN.RUECKKAUFSWERT.wiedervorlageTage).toBe(
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
)
})
it('nimmt bei der Sachstandsanfrage dieselbe Zahl wie das Vorgangsmodul', () => {
expect(SCHREIBEN_EIGENSCHAFTEN.SACHSTAND.wiedervorlageTage).toBe(NACHFASSEN_AB_TAGEN)
})
})
describe('Dateiname', () => {
const tag = new Date('2026-08-23T00:00:00.000Z')
it('beginnt mit dem Datum, damit die Dateien sortierbar bleiben', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'docx')
expect(name.startsWith('2026-08-23_')).toBe(true)
expect(name.endsWith('.docx')).toBe(true)
})
it('nennt Art und Empfänger', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'pdf')
expect(name).toContain('Kuendigung')
expect(name).toContain('Allianz')
expect(name.endsWith('.pdf')).toBe(true)
})
it('löst Umlaute auf und wirft Sonderzeichen heraus', () => {
// Der Name geht in den Objektspeicher und über einen HTTP-Kopf. Beides
// verträgt keine Umlaute und keine Anführungszeichen.
const name = dateinameFuerSchreiben('FREI', 'Müller & Co. "Süd"', tag, 'docx')
// Nur der Namensteil — der Punkt vor der Endung gehört dazu.
const ohneEndung = name.replace(/\.docx$/, '')
expect(name).not.toMatch(/[^\x20-\x7E]/)
expect(ohneEndung).not.toMatch(/["&.]/)
expect(name).toContain('Mueller')
expect(name).toContain('Sued')
})
it('kommt ohne Empfänger aus', () => {
const name = dateinameFuerSchreiben('FREI', null, tag, 'docx')
expect(name).toBe('2026-08-23_Freies-Schreiben.docx')
})
it('kürzt einen sehr langen Empfänger, statt den Namen ausufern zu lassen', () => {
const lang = 'Sehr Lange Versicherungsgesellschaft auf Gegenseitigkeit in Oberfranken'
const name = dateinameFuerSchreiben('FREI', lang, tag, 'pdf')
expect(name.length).toBeLessThan(90)
})
})
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
SPERRFRIST_TAGE,
bewerten,
heuteSchonAngefragt,
kandidatenBewerten,
type SerienKandidat,
} from '@/lib/vertraege/serienabfrage'
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert'
/**
* Serienabfrage der Rückkaufswerte — die Auswahllogik.
*
* Der teuerste Fehler wäre hier ein Brief zu viel an der falschen Stelle
* (die Gesellschaft antwortet auf die zweite Anfrage mit Verwirrung) oder
* ein stumm fehlender Vertrag (der Wert fehlt dann im Vermögensverzeichnis).
* Beides wird geprüft.
*/
const HEUTE = new Date('2026-08-23T00:00:00.000Z')
function vertrag(teile: Partial<SerienKandidat> = {}): SerienKandidat {
return {
id: 'v1',
bezeichnung: 'Lebensversicherung',
gesellschaft: 'Allianz',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
rueckkaufswertAngefragtAm: null,
...teile,
}
}
function vorTagen(tage: number): Date {
const d = new Date(HEUTE)
d.setUTCDate(d.getUTCDate() - tage)
return d
}
describe('Bewertung eines Vertrags', () => {
it('wählt eine unbefragte Lebensversicherung vor', () => {
const b = bewerten(vertrag(), HEUTE)
expect(b.moeglich).toBe(true)
expect(b.vorausgewaehlt).toBe(true)
expect(b.hindernis).toBeNull()
})
it('schließt Vertragsarten ohne Rückkaufswert aus — mit Begründung', () => {
const b = bewerten(vertrag({ art: 'HAFTPFLICHT' }), HEUTE)
expect(b.moeglich).toBe(false)
expect(b.hindernis).toContain('keinen Rückkaufswert')
})
it('schließt verwertete und abgelaufene Verträge aus', () => {
expect(bewerten(vertrag({ status: 'VERWERTET' }), HEUTE).moeglich).toBe(false)
expect(bewerten(vertrag({ status: 'ABGELAUFEN' }), HEUTE).moeglich).toBe(false)
})
it('lässt einen gekündigten Vertrag zu', () => {
// Bis zum Wirksamwerden besteht der Vertrag - und genau dann will der
// Sozialhilfeträger den Wert sehen.
const b = bewerten(vertrag({ status: 'GEKUENDIGT' }), HEUTE)
expect(b.moeglich).toBe(true)
})
it('verlangt eine Gesellschaft und sagt, was zu tun ist', () => {
const b = bewerten(vertrag({ gesellschaft: null }), HEUTE)
expect(b.moeglich).toBe(false)
expect(b.hindernis).toContain('nachtragen')
})
it('wählt einen frisch angefragten Vertrag NICHT vor, lässt ihn aber wählbar', () => {
// Der Brief könnte verloren gegangen sein - dann wählt man von Hand an.
const b = bewerten(vertrag({ rueckkaufswertAngefragtAm: vorTagen(10) }), HEUTE)
expect(b.moeglich).toBe(true)
expect(b.vorausgewaehlt).toBe(false)
expect(b.hindernis).toContain('bereits angefragt')
expect(b.tageSeitAnfrage).toBe(10)
})
it('wählt nach Ablauf der Sperrfrist wieder vor', () => {
const b = bewerten(vertrag({ rueckkaufswertAngefragtAm: vorTagen(SPERRFRIST_TAGE) }), HEUTE)
expect(b.vorausgewaehlt).toBe(true)
})
it('hält die Sperrfrist mit der Wiedervorlage im Gleichschritt', () => {
// Beide 28 Tage: Solange die Wiedervorlage läuft, ist die Antwort
// unterwegs - eine zweite Anfrage davor wäre nur Verwirrung.
expect(SPERRFRIST_TAGE).toBe(RUECKKAUFSWERT_WIEDERVORLAGE_TAGE)
})
})
describe('Sortierung der Kandidaten', () => {
it('stellt Vorausgewählte nach oben, Unmögliche ans Ende', () => {
const bewertet = kandidatenBewerten(
[
vertrag({ id: 'a', bezeichnung: 'Zulassung', art: 'HAFTPFLICHT' }),
vertrag({ id: 'b', bezeichnung: 'Beitragsfrei' }),
vertrag({ id: 'c', bezeichnung: 'Angefragt', rueckkaufswertAngefragtAm: vorTagen(5) }),
],
HEUTE,
)
expect(bewertet[0]!.vertrag.id).toBe('b')
expect(bewertet[1]!.vertrag.id).toBe('c')
expect(bewertet[2]!.vertrag.id).toBe('a')
})
})
describe('Dublettenschutz', () => {
it('erkennt die Anfrage vom selben Tag', () => {
expect(heuteSchonAngefragt(HEUTE, HEUTE)).toBe(true)
})
it('lässt eine bewusste zweite Anfrage nach einer Woche zu', () => {
// Tagesgenau, nicht sperrfristgenau: Der Doppelklick soll abgefangen
// werden, die bewusste Wiederholung (Brief verloren) nicht.
expect(heuteSchonAngefragt(vorTagen(7), HEUTE)).toBe(false)
})
it('meldet ohne Anfrage nichts', () => {
expect(heuteSchonAngefragt(null, HEUTE)).toBe(false)
})
})