Compare commits

..
6 Commits
Author SHA1 Message Date
florian.wilde 98d1338eae Merge #20: Ansprechpartner am Vorgang hinterlegen
Behebt Issue #9: Kontakt als Ansprechpartner am Vorgang, namentliche
Anrede in der Sachstandsanfrage, Name ueber der Behoerde, Anschrift im
Schreiben vorbelegt. Ohne Ansprechpartner bleibt es bei der allgemeinen
Anrede; geraten wird nie.

Nebenbei behoben: Der Schreiben-Editor verwarf die Empfaengeranschrift
aus dem Entwurf.

Closes #9
2026-08-23 14:55:49 +00:00
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00
florian.wilde 5e7d88cf64 Merge #19: Rueckkaufswert am Vertrag sichtbar machen
Behebt Issue #8: letzter Wert mit Stichtag und Quelle am Vertrag,
Kennzeichnung ab einem Jahr, "noch nicht erfasst" statt leerer Zelle.
Ohne Migration - der Wert bleibt der Wertstand am Vermoegensposten, aus
dem auch das Vermoegensverzeichnis rechnet.

Nebenbei behoben: Zuordnung ueber die Verknuepfung statt ueber die Art
des Postens, und die fehlende Zugehoerigkeitspruefung bei den Verweisen
auf Vertrag und Immobilie.

Closes #8
2026-08-23 13:29:54 +00:00
Florian Wilde 09a4528c4b Rueckkaufswert am Vertrag sichtbar machen
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat. Auf der Vertragsseite steht jetzt oben der zuletzt
mitgeteilte Wert mit Stichtag und Quelle, darunter die Historie; in der
Uebersicht traegt ein Wert, der aelter als ein Jahr ist, ein Abzeichen.

Ohne Migration, und das ist keine Einsparung, sondern der Punkt: Die
Werthistorie gibt es schon als Wertstand am Vermoegensposten, und der
kann auf einen Vertrag zeigen. Ein Feld am Vertrag waere genau die
zweite Wahrheit, die das Issue ausschliesst - dieselbe Zahl steht im
Vermoegensverzeichnis, weil es dieselbe Zahl ist.

Aufbau:
- Die Ein-Jahres-Grenze als eine getestete Regel in
  finanzen/wertalter.ts. Sie stand bisher als "> 365" mitten in der
  Vermoegensseite; jetzt gilt sie am Vertrag genauso.
- wertHistorie() fuehrt die Wertstaende aller mit dem Vertrag
  verknuepften Posten zu einer Reihe zusammen.
- Wo ein Rueckkaufswert zu erwarten waere, aber keiner erfasst ist,
  steht "noch nicht erfasst" statt einer leeren Zelle.

Zwei Fehler dabei behoben:
- Vertrag und Vermoegensposten wurden ueber die ART des Postens
  zusammengefuehrt. Ein von Hand als "Sonstiges" angelegter Posten wurde
  nicht gefunden, und beim ersten Erfassen am Vertrag entstand still ein
  zweiter - Bericht und Vertrag haetten verschiedene Zahlen gezeigt.
  Massgeblich ist jetzt die Verknuepfung.
- Beim Speichern eines Vermoegenspostens wurde geprueft, ob das
  verknuepfte Konto zur betreuten Person gehoert; bei Vertrag und
  Immobilie fehlte die Pruefung, beim Vertragsverweis einer Buchung
  ebenso. Bei mehreren betreuten Personen haette ein manipuliertes
  Formular ueber Aktengrenzen hinweg verknuepfen koennen.

Geprueft: zehn Einheitstests, vier End-to-End-Tests - darunter der neu
eingetragene Wert, der unveraendert im Vermoegensverzeichnis
wiederauftaucht, und der Posten mit abweichender Art.

Refs #8
2026-08-23 15:06:25 +02:00
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
47 changed files with 2583 additions and 50 deletions
+172 -1
View File
@@ -26,6 +26,174 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [1.9.0] – 2026-08-23
### Hinzugefügt
- **Ansprechpartner am Vorgang**
([#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9)).
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle — beim Bezirk ist das der
Unterschied zwischen zwei Wochen und zwei Monaten. Am Vorgang lässt
sich jetzt ein Kontakt aus dem Verzeichnis als Ansprechpartner
benennen.
- **Die Sachstandsanfrage redet ihn an.** Aus „Sehr geehrte Damen und
Herren" wird „Sehr geehrte Frau Sachs" — und der Name steht über der
Behörde, damit die Poststelle den Brief zur Person sortiert. Auch das
Schreiben aus dem Editor übernimmt Anrede, Empfänger und Anschrift,
sodass nichts abgetippt werden muss.
- **Anrede am Kontakt.** Damit die Briefanrede stimmt, trägt ein Kontakt
wahlweise „Frau" oder „Herr". **Geraten wird nie:** Ohne Angabe wird
mit vollem Namen angeredet („Sehr geehrte(r) Kim Berger,"). Eine
falsch geratene Anrede fällt dem Empfänger sofort auf und kostet genau
die Aufmerksamkeit, die man gewinnen wollte.
- **Vor dem Anruf weiß man, wen man fragt.** Auf der Vorgangsseite steht
der Ansprechpartner mit Organisation, Durchwahl und E-Mail, verlinkt
auf den Kontakt.
### Zwei Zusagen
**Ohne Ansprechpartner ändert sich nichts.** Ist keiner benannt, bleibt
es bei „Sehr geehrte Damen und Herren" — und die Vorgangsseite sagt das
auch, statt ein leeres Feld zu zeigen. Ein halb gefüllter Name führt nie
zu einer verstümmelten Anrede wie „Sehr geehrte Frau ,".
**Der Wechsel bleibt nachvollziehbar.** Wer den Ansprechpartner
austauscht, findet den Vorgänger im Protokoll — mit **Namen**, nicht mit
der internen Kennung. Zwei Kennungen nebeneinander wären protokolliert,
aber nicht nachvollziehbar.
### Datenbank
Eine Migration: `ansprechpartnerId` am Vorgang und `anrede` am Kontakt.
Wird ein Kontakt gelöscht, bleibt der Vorgang bestehen und verliert nur
den Verweis — ein Vorgang darf nicht daran hängen, dass es den Kontakt
noch gibt.
### Geprüft
- Neun Einheitstests für die Anrede, darunter der Nachname aus „Dr. Anna
Maria Schmitt", der Doppelname und der Fall, der nie passieren darf:
leerer Name mit gesetzter Anrede.
- Fünf End-to-End-Tests: die allgemeine Anrede ohne Ansprechpartner, das
Benennen samt Anzeige mit Durchwahl, die namentliche Anrede in der
Sachstandsanfrage, die vorbelegte Anschrift im Schreiben und der
Wechsel im Protokoll.
## [1.8.0] – 2026-08-23
### Hinzugefügt
- **Der Rückkaufswert steht jetzt beim Vertrag**
([#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8)).
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat — nicht nach einem Umweg über das Vermögensverzeichnis.
Auf der Vertragsseite steht deshalb oben der zuletzt mitgeteilte Wert
mit Stichtag und Quelle, darunter die vollständige Historie.
- **Ein alter Wert sagt, dass er alt ist.** Ist der jüngste Wert älter
als ein Jahr, steht „über ein Jahr alt" daneben — in der
Vertragsübersicht wie auf der Vertragsseite. Ein veralteter Betrag
ohne erkennbares Alter ist schlimmer als kein Betrag: Man rechnet mit
ihm weiter und merkt es erst, wenn der Bezirk nachfragt. Die Grenze von
einem Jahr gilt jetzt überall dieselbe; im Vermögensverzeichnis stand
sie schon, am Vertrag fehlte sie.
- **Verträge ohne Wert bleiben nicht stumm.** Wo ein Rückkaufswert zu
erwarten wäre, aber keiner erfasst ist, steht „noch nicht erfasst"
statt einer leeren Zelle.
### Behoben
- **Ein Vertrag hatte unter Umständen zwei Wertreihen.** Vertrag und
Vermögensposten wurden über die *Art* des Postens zusammengeführt.
Wer einen Posten von Hand anlegte und ihn als etwas anderes als
„Versicherung" führte, bekam beim ersten Eintragen am Vertrag
klammheimlich einen zweiten Posten — und damit zwei Wahrheiten, von
denen der Bericht die eine und der Vertrag die andere zeigte.
Maßgeblich ist jetzt die Verknüpfung mit dem Vertrag, nicht die Art.
- **Fehlende Prüfung bei Verweisen auf Vertrag und Immobilie.** Beim
Speichern eines Vermögenspostens wurde geprüft, ob das verknüpfte
*Konto* zur betreuten Person gehört — bei Vertrag und Immobilie fehlte
dieselbe Prüfung, ebenso beim Vertragsverweis einer Buchung. Bei
mehreren betreuten Personen hätte ein manipuliertes Formular eine
Verknüpfung über Aktengrenzen hinweg anlegen können. Jetzt wird jeder
der drei Verweise gleich behandelt.
### Geprüft
- Zehn Einheitstests: die Ein-Jahres-Grenze (auch der Tag davor und
danach), das Alter taggenau und ohne Uhrzeiteinfluss, ein künftiger
Stichtag, und die Zusammenführung mehrerer Posten zu einer Historie.
- Vier End-to-End-Tests: Wert und Stichtag in der Übersicht, das
Abzeichen am alten Wert, die Historie am Vertrag, und ein neu
eingetragener Wert, der unverändert im Vermögensverzeichnis
wiederauftaucht.
## [1.7.0] – 2026-08-23
### Hinzugefügt
- **Eigene Vorlagen für Anschreiben**
([#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7)).
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter **Einstellungen → Schreibvorlagen**: eigene Texte
hinterlegen, beim freien Schreiben auswählen — der Rest füllt sich aus
den Daten, die ohnehin im System stehen.
- **Platzhalter, die sich selbst erklären.** Neben dem Editor steht die
vollständige Liste — Name, wofür er steht, woher der Wert kommt. Ein
Klick setzt ihn an die Cursorstelle; niemand muss `{{…}}`-Namen raten
oder abtippen. Verfügbar sind unter anderem Name und Geburtsdatum der
betreuten Person, ihre Anschrift, Betreuungsgericht und Aktenzeichen,
das Bestellungsdatum, der Stichtag und das heutige Datum.
- **Vier mitgelieferte Vorlagen** als Ausgangspunkt: Auskunftsersuchen an
eine noch unbekannte Stelle, Adressänderung nach Heimeinzug, Nachweis
der Vertretungsmacht (wenn eine Stelle die Auskunft verweigert) und die
Kurzfassung der Rückkaufswert-Anfrage. Sie lassen sich nicht kaputt
bearbeiten — „Als Kopie übernehmen" legt eine eigene Fassung an, das
Original bleibt liegen. Für Rückkaufswert und Kündigung führt die
Oberfläche weiter zuerst zu den fallbezogenen Erzeugern: Die rechnen
Kündigungstermine aus, was eine Vorlage nicht kann.
### Zwei Zusagen
**Nichts geht stillschweigend schief.** Fehlt ein Wert (etwa das
Aktenzeichen), steht im Brief eine sichtbare Lücke `[Aktenzeichen des
Gerichts]` — kein Leerraum, den erst der Empfänger bemerkt. Ein
Tippfehler im Platzhalternamen bricht nicht ab, sondern wird im Text
markiert und über dem Editor benannt.
**Der Bestellungsabsatz wird nicht vergessen.** Ohne den Nachweis der
Vertretungsmacht kommt keine Auskunft — und das fällt erst nach vier
Wochen auf. Beim Speichern einer Vorlage ohne Bestellungsabsatz kommt
deshalb eine Rückfrage. Wer ihn bewusst weglässt (etwa für die zweite
Mail an eine Stelle, die den Nachweis schon hat), bestätigt einmal und
wird nicht weiter gefragt. Ein ausformulierter Absatz zählt genauso wie
der Platzhalter.
### Geändert
- Gelöschte Vorlagen liegen wie alles andere **30 Tage im Papierkorb**
und lassen sich zurückholen. Beim Vollexport der Akte sind sie mit
dabei.
### Geprüft
- Dreizehn Einheitstests für die Platzhalter-Ersetzung, darunter jeder
der drei Fälle (Wert einsetzen, sichtbare Lücke, unbekannter
Platzhalter) und die Bestellungsabsatz-Erkennung, die sich nicht von
einem halben Nachweis („Beschluss der Eigentümerversammlung") täuschen
lässt.
- Sechs End-to-End-Tests: die zweistufige Warnung, das Übernehmen einer
mitgelieferten Vorlage, die Auflösung im Editor samt markierter
Tippfehler und der Weg durch den Papierkorb.
## [1.6.0] – 2026-08-23 ## [1.6.0] – 2026-08-23
### Hinzugefügt ### Hinzugefügt
@@ -868,7 +1036,10 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
--- ---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...HEAD
[1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0
[1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0
[1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 [1.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.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.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0
+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.6.0 git checkout v1.9.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.6.0 git describe --tags # v1.9.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.6.0","commit":"..."} # {"status":"bereit","version":"1.9.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.6.0",...} # {"status":"bereit","version":"1.9.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.6.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.9.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.6.0` | auf eine bestimmte Fassung | | `./scripts/update.sh v1.9.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.6.0 git checkout v1.9.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.6.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.9.0** ([CHANGELOG](../CHANGELOG.md))
--- ---
+164
View File
@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Ansprechpartner am Vorgang — gegen die echte Anwendung.
*
* Geprüft wird die Zusage, um die es geht: Ein Schreiben zum Vorgang
* redet die Sachbearbeiterin namentlich an und trägt ihren Namen über
* der Behörde — ohne benannten Ansprechpartner bleibt alles beim Alten.
*/
const KENNUNG = 'Prüfhausen Partner'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.vorgang.deleteMany({ where: { titel: { contains: KENNUNG } } })
await db.kontakt.deleteMany({ where: { name: { contains: KENNUNG } } })
})
}
test.describe('Ansprechpartner am Vorgang', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: `${KENNUNG} Petra Sachs`,
organisation: `${KENNUNG} Bezirk`,
rolle: 'SACHBEARBEITER_BEZIRK',
anrede: 'FRAU',
telefon: '0921 604-1234',
strasse: 'Cottenbacher Str. 23',
plz: '95445',
ort: 'Bayreuth',
},
})
await db.vorgang.create({
data: {
betreuteId: person.id,
titel: `${KENNUNG} Antrag auf Hilfe zur Pflege`,
kategorie: 'SOZIALHILFE',
status: 'WARTET_AUF_DRITTE',
gegenstelle: `${KENNUNG} Bezirk`,
aktenzeichen: 'HzP 2026/4711',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('bleibt ohne benannten Ansprechpartner bei der allgemeinen Anrede', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await expect(page.getByText('Keiner benannt', { exact: false }).first()).toBeVisible()
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Damen und Herren,')
})
test('lässt einen Kontakt als Ansprechpartner wählen und zeigt ihn an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page
.locator('select[name="ansprechpartnerId"]')
.selectOption({ label: `${KENNUNG} Petra Sachs · ${KENNUNG} Bezirk` })
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
await page.reload()
const inhalt = await page.locator('body').innerText()
// Vor dem Anruf weiß man, wen man fragt — samt Durchwahl.
expect(inhalt).toContain(`${KENNUNG} Petra Sachs`)
expect(inhalt).toContain('0921 604-1234')
})
test('redet die Sachstandsanfrage namentlich an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
expect(text).not.toContain('Sehr geehrte Damen und Herren,')
// Der Name steht über der Behörde.
const zeilen = text.split('\n')
expect(zeilen[0]).toContain('Petra Sachs')
})
test('übernimmt Name und Anschrift in das Schreiben zum Vorgang', async ({ page }) => {
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { titel: { contains: KENNUNG }, ansprechpartnerId: { not: null } },
select: { id: true },
})
return vorgang?.id ?? ''
})
expect(vorgangId).not.toBe('')
await anmelden(page)
await page.goto(`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgangId}`)
await expect(page.locator('textarea#text')).toBeVisible()
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
// Empfänger und Anschrift sind vorbelegt, nichts abzutippen.
await expect(page.locator('input[name="empfaengerName"]').first()).toHaveValue(
`${KENNUNG} Petra Sachs`,
)
expect(await page.locator('textarea#empfaengerAnschrift').first().inputValue()).toContain(
'95445 Bayreuth',
)
})
test('hält den Wechsel des Ansprechpartners mit Namen im Protokoll fest', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
// Zurück auf „keiner benannt" — der Wechsel muss nachvollziehbar sein.
await page.locator('select[name="ansprechpartnerId"]').selectOption('')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
// Im Protokoll steht der Name, nicht die Id: Zwei cuids nebeneinander
// sind protokolliert, aber nicht nachvollziehbar.
await page.goto('/protokoll')
// textContent statt innerText: Die Felderliste steckt in einem
// zugeklappten <details>.
const protokoll = (await page.locator('body').textContent()) ?? ''
expect(protokoll).toContain('ansprechpartner')
expect(protokoll).toContain(`${KENNUNG} Petra Sachs`)
})
})
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Rückkaufswert am Vertrag — gegen die echte Anwendung.
*
* Die Zusage, die hier geprüft wird: Der Wert steht dort, wo man ihn
* braucht, sein Stichtag steht daneben, ein alter Wert ist als alt
* erkennbar — und es ist derselbe Wert, den das Vermögensverzeichnis
* ausweist, nicht ein zweiter danebengelegter.
*/
const KENNUNG = 'Prüfhausen Wert'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.vermoegensposten.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
})
}
/** Tag vor N Tagen, als reines Datum. */
function vorTagen(tage: number): Date {
const heute = new Date()
const ziel = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate()))
ziel.setUTCDate(ziel.getUTCDate() - tage)
return ziel
}
test.describe('Rückkaufswert am Vertrag', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
// Ein Vertrag mit frischem Wert, einer mit einem zwei Jahre alten,
// einer ohne jeden Wert.
const frisch = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Frisch`,
gesellschaft: `${KENNUNG} Leben AG`,
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
},
})
const alt = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Alt`,
gesellschaft: `${KENNUNG} Sterbekasse`,
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
},
})
await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Ohne`,
gesellschaft: `${KENNUNG} Renten AG`,
art: 'RENTENVERSICHERUNG_PRIVAT',
status: 'AKTIV',
},
})
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: frisch.id,
art: 'VERSICHERUNG',
bezeichnung: `${KENNUNG} Frisch`,
wertstaende: {
create: [
{
stichtag: vorTagen(30),
wertCent: 521055n,
wertquelle: 'Rückkaufswertmitteilung',
},
{ stichtag: vorTagen(400), wertCent: 480000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
// Bewusst als „Sonstiges" verknüpft: Die Zuordnung zum Vertrag
// hängt an der Verknüpfung, nicht an der Art des Postens.
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: alt.id,
art: 'SONSTIGES',
bezeichnung: `${KENNUNG} Alt`,
wertstaende: {
create: [
{ stichtag: vorTagen(800), wertCent: 310000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('zeigt Wert und Stichtag in der Vertragsübersicht', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('5.210,55')
// Ohne Wert bleibt die Zelle nicht leer, sondern sagt es.
expect(inhalt).toContain('noch nicht erfasst')
})
test('kennzeichnet einen über ein Jahr alten Wert', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
// Der 800 Tage alte Wert des „Alt"-Vertrags trägt das Abzeichen …
await expect(page.getByText('über ein Jahr alt').first()).toBeVisible()
// … und er wird trotz Posten-Art „Sonstiges" überhaupt gefunden.
expect(await page.locator('body').innerText()).toContain('3.100,00')
})
test('zeigt am Vertrag den letzten Wert und die Historie', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Frisch` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await expect(page.getByText('Zuletzt mitgeteilt')).toBeVisible()
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Zuletzt mitgeteilt')
expect(inhalt).toContain('5.210,55')
// Der frühere Wert bleibt abrufbar — die Entwicklung ist die Aussage.
expect(inhalt).toContain('4.800,00')
})
test('trägt einen neuen Wert am Vertrag ein — sichtbar auch im Vermögensverzeichnis', async ({
page,
}) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Ohne` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Mitgeteilten Wert eintragen' }).click()
await page.locator('input[name="stichtag"]').last().fill('2026-08-01')
await page.locator('input[name="wert"]').fill('1.234,56')
await page.getByRole('button', { name: 'Wert aufnehmen' }).click()
await expect(page.getByText('Rückkaufswert in die Historie aufgenommen.')).toBeVisible()
expect(await page.locator('body').innerText()).toContain('1.234,56')
// Dieselbe Zahl, eine Quelle: Das Vermögensverzeichnis kennt sie auch.
await page.goto('/finanzen/vermoegen?stichtag=2026-08-31')
expect(await page.locator('body').innerText()).toContain('1.234,56')
})
})
+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.6.0", "version": "1.9.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;
@@ -0,0 +1,17 @@
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
--
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- CreateEnum
CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR');
-- AlterTable
ALTER TABLE "kontakt" ADD COLUMN "anrede" "KontaktAnrede" NOT NULL DEFAULT 'OHNE';
-- AlterTable
ALTER TABLE "vorgang" ADD COLUMN "ansprechpartnerId" TEXT;
-- AddForeignKey
ALTER TABLE "vorgang" ADD CONSTRAINT "vorgang_ansprechpartnerId_fkey" FOREIGN KEY ("ansprechpartnerId") REFERENCES "kontakt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+57 -4
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[]
@@ -372,6 +373,14 @@ model Vorgang {
gegenstelle String? gegenstelle String?
aktenzeichen String? aktenzeichen String?
/// Die Person bei der Gegenstelle, die den Vorgang bearbeitet. Ein
/// namentlich adressiertes Schreiben landet bei ihr statt in der
/// Poststelle - beim Bezirk ist das der Unterschied zwischen zwei
/// Wochen und zwei Monaten. SetNull: Ein Vorgang darf nicht daran
/// haengen, dass es den Kontakt noch gibt.
ansprechpartnerId String?
ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull)
verantwortlich String? verantwortlich String?
angelegtVonId String? angelegtVonId String?
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
@@ -842,6 +851,39 @@ enum BuchungKategorie {
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere, /// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders /// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
/// zugeschnitten ist. /// zugeschnitten ist.
/// Eine eigene Schreibvorlage.
///
/// Die mitgelieferten Vorlagen (Rueckkaufswert, Kuendigung,
/// Sachstandsanfrage) stehen bewusst NICHT in dieser Tabelle - sie sind
/// Code, getestet und unveraenderlich. "Wiederherstellen" heisst: die
/// mitgelieferte Fassung als neue Kopie hierher holen.
///
/// Der Text traegt Platzhalter im Format {{name}} - aufgeloest in
/// src/lib/schreiben/platzhalter.ts. Er ist bewusst NICHT verschluesselt:
/// Eine Vorlage ist ein Textbaustein ohne Fallbezug; die Fachdaten kommen
/// erst beim Erzeugen hinein.
model Schreibvorlage {
id String @id @default(cuid())
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
name String
betreff String @default("")
text String
/// Beim Speichern bestaetigt, dass der fehlende Bestellungsabsatz
/// Absicht ist - dann wird nicht bei jedem Bearbeiten erneut gewarnt.
ohneBestellungsabsatz Boolean @default(false)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime?
@@index([betreuteId])
@@map("schreibvorlage")
}
model ZahlungsgruppeGeklaert { model ZahlungsgruppeGeklaert {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -1199,6 +1241,13 @@ model Vertrag {
// Kontakte und Kontaktprotokoll // Kontakte und Kontaktprotokoll
// ============================================================================= // =============================================================================
/// Anrede fuer die Briefanrede eines Kontakts.
enum KontaktAnrede {
OHNE
FRAU
HERR
}
enum KontaktRolle { enum KontaktRolle {
RECHTSPFLEGER RECHTSPFLEGER
RICHTER RICHTER
@@ -1233,7 +1282,10 @@ model Kontakt {
name String name String
organisation String? organisation String?
rolle KontaktRolle @default(SONSTIGES) rolle KontaktRolle @default(SONSTIGES)
/// Fuer die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet -
/// nie geraten, das faellt dem Empfaenger sofort auf.
anrede KontaktAnrede @default(OHNE)
telefon String? telefon String?
mobil String? mobil String?
@@ -1251,9 +1303,10 @@ model Kontakt {
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime? geloeschtAm DateTime?
protokolle Kontaktprotokoll[] protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte") vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[] betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle]) @@index([betreuteId, rolle])
@@map("kontakt") @@map("kontakt")
+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>
)
}
+2 -1
View File
@@ -18,6 +18,7 @@ import {
VERMOEGENS_ART_EINZAHL, VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien' } from '@/lib/finanzen/kategorien'
import { vermoegenLaden } from '@/lib/berichte/gemeinsam' import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { wertVeraltet } from '@/lib/finanzen/wertalter'
import type { SozialhilfeBewertung } from '@prisma/client' import type { SozialhilfeBewertung } from '@prisma/client'
export const metadata = { title: 'Vermögen' } export const metadata = { title: 'Vermögen' }
@@ -235,7 +236,7 @@ function Postentabelle({
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground"> <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatDatum(eintrag.bewertetAm)} {formatDatum(eintrag.bewertetAm)}
{eintrag.alterTage > 365 && ( {wertVeraltet(eintrag.alterTage) && (
<Badge variant="gelb" className="ml-2"> <Badge variant="gelb" className="ml-2">
über ein Jahr alt über ein Jahr alt
</Badge> </Badge>
@@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({
name: kontakt.name, name: kontakt.name,
organisation: kontakt.organisation, organisation: kontakt.organisation,
rolle: kontakt.rolle, rolle: kontakt.rolle,
anrede: kontakt.anrede,
telefon: kontakt.telefon, telefon: kontakt.telefon,
mobil: kontakt.mobil, mobil: kontakt.mobil,
fax: kontakt.fax, fax: kontakt.fax,
+20
View File
@@ -8,6 +8,7 @@ import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/s
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { entwurfBauen } from '@/lib/schreiben/entwurf' import { entwurfBauen } from '@/lib/schreiben/entwurf'
import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten' import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten'
import { VorlagenWahl } from '@/components/schreiben/vorlagen-wahl'
export const metadata: Metadata = { title: 'Schreiben' } export const metadata: Metadata = { title: 'Schreiben' }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -53,6 +54,7 @@ export default async function SchreibenSeite({
art, art,
vertragId: text('vertragId') || null, vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null, vorgangId: text('vorgangId') || null,
vorlageId: text('vorlageId') || null,
}) })
} catch (ausnahme) { } catch (ausnahme) {
// Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine // Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine
@@ -77,6 +79,15 @@ export default async function SchreibenSeite({
) )
} }
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({ const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { select: {
@@ -100,13 +111,22 @@ export default async function SchreibenSeite({
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]} pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]}
/> />
{art === 'FREI' && vorlagen.length > 0 && (
<VorlagenWahl vorlagen={vorlagen} aktiveVorlageId={text('vorlageId') || null} />
)}
<SchreibenEditor <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} betreuteId={aktive.id}
art={art} art={art}
entwurf={{ entwurf={{
betreff: entwurf.betreff, betreff: entwurf.betreff,
text: entwurf.text, text: entwurf.text,
empfaengerName: entwurf.empfaengerName, empfaengerName: entwurf.empfaengerName,
empfaengerAnschrift: entwurf.empfaengerAnschrift,
wiedervorlageAm: entwurf.wiedervorlageAm, wiedervorlageAm: entwurf.wiedervorlageAm,
}} }}
kontakte={kontakte} kontakte={kontakte}
+12 -3
View File
@@ -18,7 +18,12 @@ import {
kuendigungsplan, kuendigungsplan,
laufzeitAusVertrag, laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert' import {
anfrageStatus,
rueckkaufswertAnschreiben,
wertHistorie,
} from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG, BEWERTUNG_ERKLAERUNG,
@@ -44,8 +49,9 @@ export default async function VertragDetail({ params }: { params: Promise<{ id:
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: { vermoegensposten: {
where: { art: 'VERSICHERUNG', ...nichtGeloescht }, where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
}, },
}, },
@@ -76,7 +82,7 @@ export default async function VertragDetail({ params }: { params: Promise<{ id:
heute, heute,
}) })
const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? [] const wertstaende = wertHistorie(vertrag.vermoegensposten)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -226,6 +232,9 @@ export default async function VertragDetail({ params }: { params: Promise<{ id:
tageSeitAnfrage={anfrage.tageSeitAnfrage} tageSeitAnfrage={anfrage.tageSeitAnfrage}
anschreiben={anschreiben} anschreiben={anschreiben}
stichtagVorschlag={alsIsoTag(heute)} stichtagVorschlag={alsIsoTag(heute)}
alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
}
werte={wertstaende.map((w) => ({ werte={wertstaende.map((w) => ({
id: w.id, id: w.id,
stichtag: w.stichtag.toISOString(), stichtag: w.stichtag.toISOString(),
+27 -10
View File
@@ -22,10 +22,13 @@ import {
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
jahresbeitragCent, jahresbeitragCent,
kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { juengsterWert } from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import { formatCent, formatDatum } from '@/lib/utils' import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -48,8 +51,10 @@ export default async function VertraegeUebersicht() {
const vertraege = await db.vertrag.findMany({ const vertraege = await db.vertrag.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
// Ueber die Verknuepfung, nicht ueber die Art: Ein von Hand als
// "Sonstiges" angelegter Posten meint denselben Vertrag.
vermoegensposten: { vermoegensposten: {
where: { art: 'VERSICHERUNG', ...nichtGeloescht }, where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
}, },
}, },
@@ -71,6 +76,8 @@ export default async function VertraegeUebersicht() {
}) })
const ampel = kuendigungsampel(plan) const ampel = kuendigungsampel(plan)
const letzterWert = juengsterWert(vertrag.vermoegensposten)
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
@@ -97,11 +104,10 @@ export default async function VertraegeUebersicht() {
beitragCent: centZuZahl(vertrag.beitragCent), beitragCent: centZuZahl(vertrag.beitragCent),
rhythmus: vertrag.rhythmus, rhythmus: vertrag.rhythmus,
bewertung: vertrag.bewertungSozialhilfe, bewertung: vertrag.bewertungSozialhilfe,
rueckkaufswertCent: centZuZahl( rueckkaufswertCent: centZuZahl(letzterWert?.wertCent ?? null),
vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, rueckkaufswertStichtag: letzterWert?.stichtag.toISOString() ?? null,
), rueckkaufswertAlter: wertalterBestimmen(letzterWert?.stichtag ?? null, heute),
rueckkaufswertStichtag: wertErwartet: kannRueckkaufswertHaben(vertrag.art),
vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null,
ampel, ampel,
} }
}) })
@@ -201,10 +207,21 @@ export default async function VertraegeUebersicht() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{formatCent(zeile.rueckkaufswertCent)} {zeile.rueckkaufswertStichtag ? (
{zeile.rueckkaufswertStichtag && ( <>
<span className="block text-xs text-muted-foreground"> {formatCent(zeile.rueckkaufswertCent)}
{formatDatum(zeile.rueckkaufswertStichtag)} <span className="block text-xs text-muted-foreground">
{formatDatum(zeile.rueckkaufswertStichtag)}
</span>
{zeile.rueckkaufswertAlter?.veraltet && (
<Badge variant="gelb" className="mt-1">
{zeile.rueckkaufswertAlter.hinweis}
</Badge>
)}
</>
) : (
<span className="text-sm font-normal text-muted-foreground">
{zeile.wertErwartet ? 'noch nicht erfasst' : '–'}
</span> </span>
)} )}
</TableCell> </TableCell>
+51
View File
@@ -56,6 +56,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } }, verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
}, },
}) })
if (!vorgang) notFound() if (!vorgang) notFound()
@@ -65,6 +66,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Vorgang', vorgang) const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log -------------------------------------- // --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle. // Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
@@ -182,6 +191,12 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
aktenzeichenGericht: aktive.aktenzeichenGericht, aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht, betreuungsgericht: aktive.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
}) })
: null : null
@@ -285,6 +300,40 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p> <p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div> </div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p> <p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium"> <p className="font-medium">
@@ -363,6 +412,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
betreuteId={aktive.id} betreuteId={aktive.id}
vorgangId={vorgang.id} vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`} abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{ werte={{
titel: vorgang.titel, titel: vorgang.titel,
kategorie: vorgang.kategorie, kategorie: vorgang.kategorie,
@@ -371,6 +421,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
beschreibung: offen.beschreibung ?? null, beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle, gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen, aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich, verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt, naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm, faelligAm: vorgang.faelligAm,
+11
View File
@@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette' import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen' import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben') await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
const auswahl = await searchParams const auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1' const leer = auswahl.leer === '1'
@@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({
betreuteId={aktive.id} betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null} vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge" abbrechenHref="/vorgaenge"
kontakte={kontakte}
werte={{ werte={{
titel: vorlage?.titel ?? '', titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES', kategorie: vorlage?.kategorie ?? 'SONSTIGES',
@@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({
beschreibung: vorlage?.beschreibung ?? null, beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null, gegenstelle: null,
aktenzeichen: null, aktenzeichen: null,
ansprechpartnerId: null,
verantwortlich: benutzer.name, verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null, naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null, faelligAm: null,
@@ -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>
)
}
+22 -1
View File
@@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' import {
KONTAKT_ANREDEN,
KONTAKT_ROLLEN,
KONTAKT_ROLLE_BESCHRIFTUNG,
} from '@/lib/validierung/kontakte'
import { ANREDE_BESCHRIFTUNG } from '@/lib/kontakte/anrede'
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte' import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/** /**
@@ -24,6 +29,7 @@ export type KontaktWerte = {
name: string name: string
organisation: string | null organisation: string | null
rolle: string rolle: string
anrede: string
telefon: string | null telefon: string | null
mobil: string | null mobil: string | null
fax: string | null fax: string | null
@@ -91,6 +97,21 @@ export function KontaktFormular({
</Select> </Select>
</Feld> </Feld>
<Feld
id="anrede"
beschriftung="Anrede"
erklaerung="Für die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet — geraten wird nie."
fehler={zustand.felder?.anrede}
>
<Select name="anrede" defaultValue={wert('anrede') || 'OHNE'}>
{KONTAKT_ANREDEN.map((anrede) => (
<option key={anrede} value={anrede}>
{ANREDE_BESCHRIFTUNG[anrede]}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen dort" beschriftung="Aktenzeichen dort"
+10 -2
View File
@@ -59,7 +59,13 @@ export function SchreibenEditor({
}: { }: {
betreuteId: string betreuteId: string
art: SchreibenArt art: SchreibenArt
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string } entwurf: {
betreff: string
text: string
empfaengerName: string
empfaengerAnschrift: string
wiedervorlageAm: string
}
kontakte: KontaktAuswahl[] kontakte: KontaktAuswahl[]
vertragId: string | null vertragId: string | null
vorgangId: string | null vorgangId: string | null
@@ -69,7 +75,9 @@ export function SchreibenEditor({
const [text, setText] = React.useState(entwurf.text) const [text, setText] = React.useState(entwurf.text)
const [betreff, setBetreff] = React.useState(entwurf.betreff) const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState(
entwurf.empfaengerAnschrift,
)
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('') const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF') const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false) const [betreffKopiert, setBetreffKopiert] = React.useState(false)
@@ -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>
)
}
+30 -3
View File
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { Coins, FileDown, Mail, Plus } from 'lucide-react' import { Coins, FileDown, Mail, Plus } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -36,6 +37,7 @@ export function Rueckkaufswert({
anschreiben, anschreiben,
stichtagVorschlag, stichtagVorschlag,
darfSchreiben, darfSchreiben,
alterHinweis,
}: { }: {
betreuteId: string betreuteId: string
vertragId: string vertragId: string
@@ -46,6 +48,8 @@ export function Rueckkaufswert({
anschreiben: string anschreiben: string
stichtagVorschlag: string stichtagVorschlag: string
darfSchreiben: boolean darfSchreiben: boolean
/** Kurztext, wenn der juengste Wert zu alt ist - sonst null. */
alterHinweis: string | null
}) { }) {
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>( const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
rueckkaufswertErfassen, rueckkaufswertErfassen,
@@ -55,6 +59,9 @@ export function Rueckkaufswert({
rueckkaufswertAnfordern, rueckkaufswertAnfordern,
{}, {},
) )
// Die Liste kommt bereits juengster Wert zuerst von der Seite.
const letzter = werte[0] ?? null
const [briefOffen, setBriefOffen] = useState(false) const [briefOffen, setBriefOffen] = useState(false)
const [erfassenOffen, setErfassenOffen] = useState(false) const [erfassenOffen, setErfassenOffen] = useState(false)
@@ -88,7 +95,7 @@ export function Rueckkaufswert({
</Alert> </Alert>
)} )}
{werte.length === 0 ? ( {!letzter ? (
<Leerzustand <Leerzustand
titel="Noch kein Rückkaufswert erfasst" titel="Noch kein Rückkaufswert erfasst"
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben." beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
@@ -103,7 +110,26 @@ export function Rueckkaufswert({
} }
/> />
) : ( ) : (
<Table> <>
{/* Der zuletzt mitgeteilte Wert gehoert nach oben: Wer beim
Telefonat mit der Gesellschaft danach gefragt wird, hat
keine Zeit, eine Historie zu lesen. */}
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Zuletzt mitgeteilt</p>
<p className="text-2xl font-semibold tabular-nums">
{formatCent(letzter.wertCent)}
</p>
<p className="text-sm text-muted-foreground">
zum Stichtag {formatDatum(letzter.stichtag)} · {letzter.wertquelle}
</p>
{alterHinweis && (
<Badge variant="gelb" className="mt-2">
{alterHinweis} — vor dem nächsten Bericht neu anfragen
</Badge>
)}
</div>
<Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Stichtag</TableHead> <TableHead>Stichtag</TableHead>
@@ -126,7 +152,8 @@ export function Rueckkaufswert({
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</>
)} )}
{darfSchreiben && ( {darfSchreiben && (
@@ -36,6 +36,7 @@ export type VorgangsFormularWerte = {
beschreibung: string | null beschreibung: string | null
gegenstelle: string | null gegenstelle: string | null
aktenzeichen: string | null aktenzeichen: string | null
ansprechpartnerId: string | null
verantwortlich: string | null verantwortlich: string | null
naechsterSchritt: string | null naechsterSchritt: string | null
faelligAm: Date | null faelligAm: Date | null
@@ -50,6 +51,7 @@ export function VorgangsFormular({
vorlageSchluessel, vorlageSchluessel,
werte, werte,
abbrechenHref, abbrechenHref,
kontakte,
}: { }: {
modus: 'anlegen' | 'aendern' modus: 'anlegen' | 'aendern'
betreuteId: string betreuteId: string
@@ -57,6 +59,8 @@ export function VorgangsFormular({
vorlageSchluessel?: string | null vorlageSchluessel?: string | null
werte: VorgangsFormularWerte werte: VorgangsFormularWerte
abbrechenHref: string abbrechenHref: string
/** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */
kontakte: { id: string; name: string; organisation: string | null }[]
}) { }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>( const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern, modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
@@ -146,6 +150,22 @@ export function VorgangsFormular({
/> />
</Feld> </Feld>
<Feld
id="ansprechpartnerId"
beschriftung="Ansprechpartner"
erklaerung="Wer dort die Sache bearbeitet. Schreiben zu diesem Vorgang reden ihn namentlich an — das bringt sie an der Poststelle vorbei."
fehler={zustand.felder?.ansprechpartnerId}
>
<Select name="ansprechpartnerId" defaultValue={werte.ansprechpartnerId ?? ''}>
<option value="">– keiner benannt –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{[kontakt.name, kontakt.organisation].filter(Boolean).join(' · ')}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen" beschriftung="Aktenzeichen"
+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',
+52
View File
@@ -0,0 +1,52 @@
import { tageZwischen } from '@/lib/finanzen/datum'
/**
* Wie alt ist ein Wert - und ab wann ist das ein Problem?
*
* Ein Rueckkaufswert ohne Datum ist schlimmer als kein Rueckkaufswert:
* Man rechnet mit einer Zahl, die drei Jahre alt sein kann, und merkt es
* erst, wenn der Bezirk nachfragt. Deshalb steht neben jedem Betrag der
* Stichtag, und ab einem Jahr steht daneben, dass er alt ist.
*
* Die Grenze liegt bei 365 Tagen, weil der Bezirk jaehrlich prueft und
* das Betreuungsgericht jaehrlich Rechnung legen laesst. Ein Wert, der
* den letzten Jahresbericht ueberlebt hat, gehoert erneuert.
*
* Bewusst eine eigene Datei und nicht in vermoegen.ts: Die Regel gilt am
* Vertrag genauso wie im Vermoegensverzeichnis, und sie soll an beiden
* Stellen dieselbe sein - nicht zweimal als `> 365` hingeschrieben.
*/
export const WERT_VERALTET_TAGE = 365
export type Wertalter = {
/** Tage zwischen Stichtag und heute. Negativ bei kuenftigem Stichtag. */
alterTage: number
veraltet: boolean
/** Kurztext fuer ein Abzeichen neben dem Betrag, sonst null. */
hinweis: string | null
}
/** Ist ein Wert dieses Alters erneuerungsbeduerftig? */
export function wertVeraltet(alterTage: number): boolean {
return alterTage > WERT_VERALTET_TAGE
}
/**
* Das Alter eines Werts zum Stichtag.
*
* Ohne Stichtag gibt es kein Alter - dann null, und die Oberflaeche
* zeigt gar nichts an, statt eine Null zu behaupten.
*/
export function wertalterBestimmen(stichtag: Date | null, heute: Date): Wertalter | null {
if (!stichtag) return null
const alterTage = tageZwischen(stichtag, heute)
const veraltet = wertVeraltet(alterTage)
return {
alterTage,
veraltet,
hinweis: veraltet ? 'über ein Jahr alt' : null,
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { KontaktAnrede } from '@prisma/client'
/**
* Die Briefanrede eines Ansprechpartners.
*
* Ein namentlich adressiertes Schreiben landet bei der Person, die den
* Vorgang kennt, statt in der Poststelle. Beim Bezirk Oberfranken ist das
* der Unterschied zwischen zwei Wochen und zwei Monaten.
*
* Zwei Regeln, die hier nicht verhandelbar sind:
*
* 1. Es wird nie geraten. Ohne hinterlegte Anrede wird mit dem vollen
* Namen angeredet ("Sehr geehrte(r) Anna Schmitt,"). Eine falsch
* geratene Anrede faellt dem Empfaenger sofort auf und kostet genau
* die Aufmerksamkeit, die man gewinnen wollte.
* 2. Ohne Ansprechpartner bleibt es bei "Sehr geehrte Damen und Herren,".
* Ein halb gefuellter Name darf nie zu "Sehr geehrte Frau ," werden -
* lieber unpersoenlich als offensichtlich automatisch.
*/
export const ANREDE_BESCHRIFTUNG: Record<KontaktAnrede, string> = {
OHNE: 'keine Angabe',
FRAU: 'Frau',
HERR: 'Herr',
}
export const ALLGEMEINE_ANREDE = 'Sehr geehrte Damen und Herren,'
/**
* Der Nachname aus einem vollen Namen.
*
* Titel und Vornamen fallen weg: "Dr. Anna Schmitt" wird zu "Schmitt".
* Doppelnamen mit Bindestrich bleiben ganz, weil sie ein Wort sind.
*/
export function nachnameAus(name: string): string {
const teile = name.trim().split(/\s+/).filter(Boolean)
return teile.length > 0 ? teile[teile.length - 1]! : ''
}
export type Ansprechpartner = {
name: string
anrede: KontaktAnrede
}
/**
* Die Anredezeile fuer ein Schreiben, einschliesslich Komma.
*
* Ohne Ansprechpartner - oder mit einem, dessen Name leer ist - die
* allgemeine Anrede.
*/
export function anredeZeile(ansprechpartner: Ansprechpartner | null): string {
if (!ansprechpartner) return ALLGEMEINE_ANREDE
const name = ansprechpartner.name.trim()
if (!name) return ALLGEMEINE_ANREDE
if (ansprechpartner.anrede === 'FRAU') return `Sehr geehrte Frau ${nachnameAus(name)},`
if (ansprechpartner.anrede === 'HERR') return `Sehr geehrter Herr ${nachnameAus(name)},`
return `Sehr geehrte(r) ${name},`
}
+91 -3
View File
@@ -19,6 +19,8 @@ import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { heuteAlsTag } from '@/lib/fristen/datum' import { heuteAlsTag } from '@/lib/fristen/datum'
import { SCHREIBEN_EIGENSCHAFTEN, type SchreibenArt } from '@/lib/schreiben/arten' 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. * Baut den Entwurf eines Schreibens - Text, Betreff, Empfaenger.
@@ -109,6 +111,8 @@ export type EntwurfEingabe = {
art: SchreibenArt art: SchreibenArt
vertragId?: string | null vertragId?: string | null
vorgangId?: string | null vorgangId?: string | null
/** Eigene Vorlage als Grundlage des freien Schreibens. */
vorlageId?: string | null
/** Fuer den Test ueberschreibbar. */ /** Fuer den Test ueberschreibbar. */
heute?: Date heute?: Date
} }
@@ -197,8 +201,19 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const vorgang = await db.vorgang.findFirst({ const vorgang = await db.vorgang.findFirst({
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht }, where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
// Der benannte Ansprechpartner, nicht irgendeiner der verknuepften
// Kontakte: Wer angeredet wird, ist eine Entscheidung und kein Zufall.
include: { include: {
kontakte: { select: { name: true, organisation: true }, take: 1 }, ansprechpartner: {
select: {
name: true,
anrede: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
},
},
}, },
}) })
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).') if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
@@ -218,19 +233,92 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
aktenzeichenGericht: betreute.aktenzeichenGericht, aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht, betreuungsgericht: betreute.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner,
heute, heute,
}) })
// Ist ein Ansprechpartner benannt, kommt seine Anschrift gleich mit -
// sie steht im Kontaktverzeichnis und muesste sonst abgetippt werden.
const anschrift = vorgang.ansprechpartner
? [
vorgang.ansprechpartner.strasse,
[vorgang.ansprechpartner.plz, vorgang.ansprechpartner.ort]
.filter(Boolean)
.join(' ')
.trim() || null,
]
.filter(Boolean)
.join('\n')
: ''
return { return {
...gemeinsam, ...gemeinsam,
text: anfrage.text, text: anfrage.text,
betreff: anfrage.betreff, betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '', empfaengerName:
vorgang.ansprechpartner?.name ?? vorgang.gegenstelle ?? '',
empfaengerAnschrift: anschrift,
}
}
// --- Freies Schreiben, wahlweise aus eigener Vorlage -----------------------
if (eingabe.vorlageId) {
const vorlage = await db.schreibvorlage.findFirst({
where: { id: eingabe.vorlageId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
})
if (!vorlage) throw new Error('Diese Vorlage gibt es nicht (mehr).')
// Die Werte, die eine Vorlage kennen kann. Vertragsbezogene bleiben
// hier leer und erscheinen als sichtbare Luecke - ein freies Schreiben
// hat keinen Vertrag.
const werte: PlatzhalterWerte = {
betreute_name: kopf.betreuteName,
betreute_geburtsdatum: kopf.betreuteGeburtsdatum
? formatDatum(kopf.betreuteGeburtsdatum)
: null,
betreute_anschrift: kopf.betreuteAnschrift,
betreuer_name: kopf.betreuerName,
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: '', 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.`,
]
: [],
} }
} }
// --- Freies Schreiben ------------------------------------------------------
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute) const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
return { return {
...gemeinsam, ...gemeinsam,
+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'),
},
]
+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
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import type { KontaktArt, KontaktRolle } from '@prisma/client' import type { KontaktAnrede, KontaktArt, KontaktRolle } from '@prisma/client'
/** /**
* Eingabepruefung fuer Kontakte und Kontaktprotokolle. * Eingabepruefung fuer Kontakte und Kontaktprotokolle.
@@ -101,6 +101,13 @@ export const KONTAKT_ROLLEN = [
'SONSTIGES', 'SONSTIGES',
] as const satisfies readonly KontaktRolle[] ] as const satisfies readonly KontaktRolle[]
/** Anreden fuer die Briefanrede - Reihenfolge der Auswahlliste. */
export const KONTAKT_ANREDEN = [
'OHNE',
'FRAU',
'HERR',
] as const satisfies readonly KontaktAnrede[]
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = { export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
RECHTSPFLEGER: 'Rechtspfleger', RECHTSPFLEGER: 'Rechtspfleger',
RICHTER: 'Richter', RICHTER: 'Richter',
@@ -177,6 +184,7 @@ export const KontaktSchema = z.object({
rolle: z.enum(KONTAKT_ROLLEN, { rolle: z.enum(KONTAKT_ROLLEN, {
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }), errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
}), }),
anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'),
telefon: optionalerText(50), telefon: optionalerText(50),
mobil: optionalerText(50), mobil: optionalerText(50),
fax: optionalerText(50), fax: optionalerText(50),
@@ -198,6 +206,7 @@ export function kontaktFormularLesen(formular: FormData): Record<string, unknown
for (const feld of [ for (const feld of [
'name', 'name',
'organisation', 'organisation',
'anrede',
'rolle', 'rolle',
'telefon', 'telefon',
'mobil', 'mobil',
+9
View File
@@ -19,6 +19,14 @@ const optionalerText = (max = 200) =>
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
/** Ein Verweis auf einen anderen Datensatz - leer heisst "keiner". */
const optionaleId = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z const optionalesDatum = z
.string() .string()
.trim() .trim()
@@ -90,6 +98,7 @@ export const VorgangSchema = z.object({
beschreibung: optionalerText(4000), beschreibung: optionalerText(4000),
gegenstelle: optionalerText(150), gegenstelle: optionalerText(150),
aktenzeichen: optionalerText(80), aktenzeichen: optionalerText(80),
ansprechpartnerId: optionaleId,
verantwortlich: optionalerText(120), verantwortlich: optionalerText(120),
naechsterSchritt: optionalerText(300), naechsterSchritt: optionalerText(300),
faelligAm: optionalesDatum, faelligAm: optionalesDatum,
+1 -1
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.6.0' export const VERSION = '1.9.0'
/** /**
* Kurzer Git-Stand, beim Bauen hineingereicht: * Kurzer Git-Stand, beim Bauen hineingereicht:
+28
View File
@@ -114,3 +114,31 @@ export function anfrageStatus(
tageSeitAnfrage: tage, tageSeitAnfrage: tage,
} }
} }
/**
* Die Rueckkaufswert-Historie eines Vertrags, juengster Wert zuerst.
*
* Der Wert lebt nicht am Vertrag, sondern als Wertstand am
* Vermoegensposten - genau derselbe, aus dem das Vermoegensverzeichnis
* rechnet. Es gibt also keine zweite Zahl, die abweichen koennte, und
* der Vertrag zeigt nur an, was ohnehin schon in der Akte steht.
*
* Verknuepft wird ueber die Vertrags-Id, nicht ueber die Art des Postens.
* Wer einen Posten von Hand als "Sonstiges" anlegt und mit dem Vertrag
* verknuepft, meint denselben Vertrag - er darf dadurch nicht eine
* zweite, unsichtbare Wahrheit bekommen.
*/
export function wertHistorie<T extends { stichtag: Date }>(
posten: { wertstaende: T[] }[],
): T[] {
return posten
.flatMap((p) => p.wertstaende)
.sort((a, b) => b.stichtag.getTime() - a.stichtag.getTime())
}
/** Der zuletzt mitgeteilte Wert, oder null, wenn noch keiner vorliegt. */
export function juengsterWert<T extends { stichtag: Date }>(
posten: { wertstaende: T[] }[],
): T | null {
return wertHistorie(posten)[0] ?? null
}
+13 -6
View File
@@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns' import { differenceInCalendarDays } from 'date-fns'
import { anredeZeile, type Ansprechpartner } from '@/lib/kontakte/anrede'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
/** /**
@@ -36,8 +37,12 @@ export type SachstandsanfrageEingabe = {
aktenzeichenGericht?: string | null aktenzeichenGericht?: string | null
betreuungsgericht?: string | null betreuungsgericht?: string | null
} }
/** Ansprechpartner bei der Gegenstelle, wenn bekannt. */ /**
ansprechpartner?: string | null * Ansprechpartner bei der Gegenstelle, wenn am Vorgang benannt.
* Bestimmt die Anrede - ohne ihn bleibt es bei "Sehr geehrte Damen
* und Herren".
*/
ansprechpartner?: Ansprechpartner | null
/** Für den Test überschreibbar. */ /** Für den Test überschreibbar. */
heute?: Date heute?: Date
} }
@@ -69,13 +74,15 @@ export function sachstandsanfrageErzeugen(
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
const anrede = eingabe.ansprechpartner const anrede = anredeZeile(eingabe.ansprechpartner ?? null)
? `Sehr geehrte(r) ${eingabe.ansprechpartner},`
: 'Sehr geehrte Damen und Herren,'
const zeilen: string[] = [] const zeilen: string[] = []
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle, '') // Der Name steht ueber der Stelle, nicht darunter: So sortiert die
// Poststelle den Brief zur Person und nicht ins allgemeine Fach.
if (eingabe.ansprechpartner?.name.trim()) zeilen.push(eingabe.ansprechpartner.name.trim())
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle)
if (eingabe.ansprechpartner?.name.trim() || eingabe.gegenstelle) zeilen.push('')
zeilen.push(formatDatum(heute), '') zeilen.push(formatDatum(heute), '')
zeilen.push(`Betreff: ${betreff}`, '') zeilen.push(`Betreff: ${betreff}`, '')
zeilen.push(anrede, '') zeilen.push(anrede, '')
+1
View File
@@ -50,6 +50,7 @@ const PAPIERKORB_TABELLEN = [
'bescheid', 'bescheid',
'leistung', 'leistung',
'bericht', 'bericht',
'schreibvorlage',
'betreute', 'betreute',
] as const ] as const
+30
View File
@@ -332,6 +332,15 @@ export async function buchungSpeichern(
}) })
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' } if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
} }
// Wie beim Konto: Der Vertrag muss zu dieser Akte gehoeren.
if (werte.vertragId) {
const vertrag = await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
}
if (werte.belegId) { if (werte.belegId) {
const beleg = await db.dokument.findFirst({ const beleg = await db.dokument.findFirst({
where: { id: werte.belegId, betreuteId, ...nichtGeloescht }, where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
@@ -634,6 +643,27 @@ export async function vermoegenspostenSpeichern(
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' } if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
} }
// Dieselbe Pruefung fuer die beiden anderen Verweise. Sie fehlte:
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert, und
// die darf nicht in eine fremde Akte zeigen.
if (werte.vertragId) {
const vertrag = await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' }
}
if (werte.immobilieId) {
const immobilie = await db.immobilie.findFirst({
where: { id: werte.immobilieId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!immobilie) {
return { fehler: 'Diese Immobilie gehört nicht zu dieser betreuten Person.' }
}
}
const felder = { const felder = {
bezeichnung: werte.bezeichnung, bezeichnung: werte.bezeichnung,
art: werte.art, art: werte.art,
+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',
+6 -1
View File
@@ -145,8 +145,13 @@ export async function vertragLoeschen(betreuteId: string, vertragId: string): Pr
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) { async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) {
// Gesucht wird ueber die Verknuepfung, nicht ueber die Art. Sonst
// entsteht neben einem von Hand als "Sonstiges" angelegten Posten ein
// zweiter - und derselbe Vertrag haette zwei Wertreihen, von denen der
// Bericht die eine und der Vertrag die andere zeigt.
const vorhanden = await db.vermoegensposten.findFirst({ const vorhanden = await db.vermoegensposten.findFirst({
where: { betreuteId, vertragId, art: 'VERSICHERUNG', ...nichtGeloescht }, where: { betreuteId, vertragId, ...nichtGeloescht },
orderBy: { erstelltAm: 'asc' },
}) })
if (vorhanden) return vorhanden if (vorhanden) return vorhanden
return db.vermoegensposten.create({ return db.vermoegensposten.create({
+68 -1
View File
@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang } from '@/lib/auth/sitzung' import { erfordereZugang } from '@/lib/auth/sitzung'
import { aenderungErmitteln, protokollieren } from '@/lib/audit' import { aenderungErmitteln, protokollieren, type Aenderung } from '@/lib/audit'
import { oeffnen, sealen } from '@/lib/crypto/feld' import { oeffnen, sealen } from '@/lib/crypto/feld'
import { import {
AufgabeAbhakenSchema, AufgabeAbhakenSchema,
@@ -58,6 +58,45 @@ function pfadeAuffrischen(vorgangId?: string): void {
// Anlegen // Anlegen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Der Name eines Kontakts fuers Protokoll - null bleibt null. */
async function kontaktnameZu(kontaktId: string | null): Promise<string | null> {
if (!kontaktId) return null
const kontakt = await db.kontakt.findUnique({
where: { id: kontaktId },
select: { name: true, organisation: true },
})
if (!kontakt) return null
return [kontakt.name, kontakt.organisation].filter(Boolean).join(', ')
}
/**
* Gehoert der Ansprechpartner zu dieser Akte?
*
* Ohne die Pruefung koennte ein veraendertes Formular einen Kontakt aus
* einer fremden Akte an den Vorgang haengen - und sein Name stuende
* anschliessend im Briefkopf.
*/
async function ansprechpartnerPruefen(
betreuteId: string,
ansprechpartnerId: string | null,
): Promise<{ ok: true } | { ok: false; zustand: Formularzustand }> {
if (!ansprechpartnerId) return { ok: true }
const kontakt = await db.kontakt.findFirst({
where: { id: ansprechpartnerId, betreuteId, geloeschtAm: null },
select: { id: true },
})
if (kontakt) return { ok: true }
return {
ok: false,
zustand: {
fehler: 'Diesen Kontakt gibt es nicht (mehr).',
felder: { ansprechpartnerId: 'Bitte einen Kontakt aus dem Verzeichnis wählen.' },
},
}
}
export async function vorgangAnlegen( export async function vorgangAnlegen(
_vorher: Formularzustand, _vorher: Formularzustand,
formular: FormData, formular: FormData,
@@ -79,6 +118,10 @@ export async function vorgangAnlegen(
} }
const eingabe = gelesen.data const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
const jetzt = new Date() const jetzt = new Date()
// Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles // Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles
@@ -101,6 +144,7 @@ export async function vorgangAnlegen(
prioritaet: eingabe.prioritaet, prioritaet: eingabe.prioritaet,
gegenstelle: eingabe.gegenstelle, gegenstelle: eingabe.gegenstelle,
aktenzeichen: eingabe.aktenzeichen, aktenzeichen: eingabe.aktenzeichen,
ansprechpartnerId: eingabe.ansprechpartnerId,
verantwortlich: eingabe.verantwortlich ?? benutzer.name, verantwortlich: eingabe.verantwortlich ?? benutzer.name,
naechsterSchritt: naechsterSchritt:
eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null, eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null,
@@ -205,6 +249,10 @@ export async function vorgangAendern(
} }
const eingabe = gelesen.data const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(vorgang.betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
const vorherOffen = oeffnen('Vorgang', vorgang) const vorherOffen = oeffnen('Vorgang', vorgang)
const daten: Prisma.VorgangUpdateInput = { const daten: Prisma.VorgangUpdateInput = {
@@ -214,6 +262,11 @@ export async function vorgangAendern(
prioritaet: eingabe.prioritaet, prioritaet: eingabe.prioritaet,
gegenstelle: eingabe.gegenstelle, gegenstelle: eingabe.gegenstelle,
aktenzeichen: eingabe.aktenzeichen, aktenzeichen: eingabe.aktenzeichen,
// Ueber die Beziehung, nicht ueber das Feld: connect/disconnect ist
// das, was Prisma bei einer Update-Eingabe erwartet.
ansprechpartner: eingabe.ansprechpartnerId
? { connect: { id: eingabe.ansprechpartnerId } }
: { disconnect: true },
verantwortlich: eingabe.verantwortlich, verantwortlich: eingabe.verantwortlich,
naechsterSchritt: eingabe.naechsterSchritt, naechsterSchritt: eingabe.naechsterSchritt,
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
@@ -227,6 +280,19 @@ export async function vorgangAendern(
const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten }) const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten })
// Der Wechsel des Ansprechpartners gehoert mit NAMEN ins Protokoll.
// Zwei cuids nebeneinander sind protokolliert, aber nicht
// nachvollziehbar - und nachvollziehbar war die Anforderung.
const ansprechpartnerWechsel: Aenderung =
vorgang.ansprechpartnerId !== geaendert.ansprechpartnerId
? {
ansprechpartner: {
alt: await kontaktnameZu(vorgang.ansprechpartnerId),
neu: await kontaktnameZu(geaendert.ansprechpartnerId),
},
}
: {}
await protokollieren({ await protokollieren({
aktion: 'GEAENDERT', aktion: 'GEAENDERT',
entitaet: 'Vorgang', entitaet: 'Vorgang',
@@ -238,6 +304,7 @@ export async function vorgangAendern(
userName: kontext.benutzer.name, userName: kontext.benutzer.name,
aenderung: { aenderung: {
...aenderungErmitteln(vorgang, geaendert), ...aenderungErmitteln(vorgang, geaendert),
...ansprechpartnerWechsel,
...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null) ...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null)
? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } } ? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } }
: {}), : {}),
+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.' }
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { ALLGEMEINE_ANREDE, anredeZeile, nachnameAus } from '@/lib/kontakte/anrede'
/**
* Die Briefanrede.
*
* Zwei Zusagen werden hier gepruft: Es wird nie geraten, und ein halb
* gefuellter Name fuehrt nie zu einer verstuemmelten Anrede.
*/
describe('Nachname', () => {
it('nimmt das letzte Wort', () => {
expect(nachnameAus('Anna Schmitt')).toBe('Schmitt')
})
it('lässt Titel und Vornamen weg', () => {
expect(nachnameAus('Dr. Anna Maria Schmitt')).toBe('Schmitt')
})
it('hält einen Doppelnamen zusammen', () => {
expect(nachnameAus('Anna Schmitt-Berger')).toBe('Schmitt-Berger')
})
it('verträgt überzählige Leerzeichen', () => {
expect(nachnameAus(' Anna Schmitt ')).toBe('Schmitt')
})
})
describe('Anredezeile', () => {
it('redet eine Frau mit Nachnamen an', () => {
expect(anredeZeile({ name: 'Anna Schmitt', anrede: 'FRAU' })).toBe(
'Sehr geehrte Frau Schmitt,',
)
})
it('redet einen Herrn mit Nachnamen an', () => {
expect(anredeZeile({ name: 'Peter Vogel', anrede: 'HERR' })).toBe(
'Sehr geehrter Herr Vogel,',
)
})
it('rät nicht, wenn die Anrede fehlt', () => {
// Lieber die etwas steife Form als ein falsch geratenes "Frau".
expect(anredeZeile({ name: 'Kim Berger', anrede: 'OHNE' })).toBe(
'Sehr geehrte(r) Kim Berger,',
)
})
it('bleibt ohne Ansprechpartner allgemein', () => {
expect(anredeZeile(null)).toBe(ALLGEMEINE_ANREDE)
})
it('wird bei leerem Namen nicht verstümmelt', () => {
// Der Fehler, der hier nie passieren darf: "Sehr geehrte Frau ,".
expect(anredeZeile({ name: ' ', anrede: 'FRAU' })).toBe(ALLGEMEINE_ANREDE)
})
})
+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)
})
})
+28
View File
@@ -11,7 +11,9 @@ import {
import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent' import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent'
import { import {
anfrageStatus, anfrageStatus,
juengsterWert,
rueckkaufswertAnschreiben, rueckkaufswertAnschreiben,
wertHistorie,
wiedervorlageAm, wiedervorlageAm,
} from '@/lib/vertraege/rueckkaufswert' } from '@/lib/vertraege/rueckkaufswert'
import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten' import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
@@ -313,3 +315,29 @@ describe('Vertragsdauer aus Beginn und Ende', () => {
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1)) expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
}) })
}) })
describe('Rückkaufswert-Historie am Vertrag', () => {
const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent })
it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => {
// Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim
// ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine.
const historie = wertHistorie([
{ wertstaende: [wert(2024, 4000)] },
{ wertstaende: [wert(2026, 5200), wert(2025, 4600)] },
])
expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000])
})
it('nennt den zuletzt mitgeteilten Wert', () => {
expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe(
5200,
)
})
it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => {
expect(juengsterWert([{ wertstaende: [] }])).toBeNull()
expect(juengsterWert([])).toBeNull()
})
})
+14 -2
View File
@@ -481,8 +481,20 @@ describe('Sachstandsanfrage', () => {
}) })
it('spricht einen bekannten Ansprechpartner direkt an', () => { it('spricht einen bekannten Ansprechpartner direkt an', () => {
const anfrage = sachstandsanfrageErzeugen({ ...eingabe, ansprechpartner: 'Frau Schmidt' }) const anfrage = sachstandsanfrageErzeugen({
expect(anfrage.text).toContain('Sehr geehrte(r) Frau Schmidt,') ...eingabe,
ansprechpartner: { name: 'Petra Schmidt', anrede: 'FRAU' },
})
expect(anfrage.text).toContain('Sehr geehrte Frau Schmidt,')
// Der Name steht ueber der Stelle - so landet der Brief bei ihr.
const zeilen = anfrage.text.split('\n')
expect(zeilen[0]).toBe('Petra Schmidt')
expect(zeilen[1]).toBe('Bezirk Oberfranken')
})
it('bleibt ohne Ansprechpartner bei der allgemeinen Anrede', () => {
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.text).toContain('Sehr geehrte Damen und Herren,')
}) })
it('hält Nachfassen erst nach sechs Wochen für angebracht', () => { it('hält Nachfassen erst nach sechs Wochen für angebracht', () => {
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
WERT_VERALTET_TAGE,
wertalterBestimmen,
wertVeraltet,
} from '@/lib/finanzen/wertalter'
/**
* Das Alter eines Rueckkaufswerts.
*
* Die Grenze ist eine fachliche Aussage, keine Zierde: Ein Wert, der den
* letzten Jahresbericht ueberlebt hat, gehoert erneuert.
*/
const tag = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
describe('Grenze', () => {
it('liegt bei einem Jahr', () => {
expect(WERT_VERALTET_TAGE).toBe(365)
})
it('ist am 365. Tag noch nicht ueberschritten', () => {
expect(wertVeraltet(365)).toBe(false)
expect(wertVeraltet(366)).toBe(true)
})
})
describe('Alter bestimmen', () => {
it('rechnet in Kalendertagen', () => {
const alter = wertalterBestimmen(tag('2026-01-01'), tag('2026-01-31'))
expect(alter?.alterTage).toBe(30)
expect(alter?.veraltet).toBe(false)
expect(alter?.hinweis).toBeNull()
})
it('meldet einen Wert von vor zwei Jahren als veraltet', () => {
const alter = wertalterBestimmen(tag('2024-03-01'), tag('2026-03-01'))
expect(alter?.veraltet).toBe(true)
expect(alter?.hinweis).toBe('über ein Jahr alt')
})
it('behauptet ohne Stichtag kein Alter', () => {
// Kein Datum heisst nicht "heute" - es heisst "unbekannt".
expect(wertalterBestimmen(null, tag('2026-03-01'))).toBeNull()
})
it('zaehlt einen kuenftigen Stichtag negativ und nicht als veraltet', () => {
// Kommt vor: Der Wert wird zum Quartalsende angefragt und vorab erfasst.
const alter = wertalterBestimmen(tag('2026-06-30'), tag('2026-06-01'))
expect(alter?.alterTage).toBe(-29)
expect(alter?.veraltet).toBe(false)
})
it('ist taggenau und nicht uhrzeitabhaengig', () => {
const spaetAmTag = new Date('2026-03-01T23:30:00.000Z')
expect(wertalterBestimmen(tag('2026-03-01'), spaetAmTag)?.alterTage).toBe(0)
})
})