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
This commit is contained in:
+62
-1
@@ -26,6 +26,66 @@ Wiederherstellung*.
|
|||||||
|
|
||||||
_Noch nichts._
|
_Noch nichts._
|
||||||
|
|
||||||
|
## [1.7.0] – 2026-08-23
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Eigene Vorlagen für Anschreiben**
|
||||||
|
([#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7)).
|
||||||
|
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
|
||||||
|
falsch. Neu unter **Einstellungen → Schreibvorlagen**: eigene Texte
|
||||||
|
hinterlegen, beim freien Schreiben auswählen — der Rest füllt sich aus
|
||||||
|
den Daten, die ohnehin im System stehen.
|
||||||
|
|
||||||
|
- **Platzhalter, die sich selbst erklären.** Neben dem Editor steht die
|
||||||
|
vollständige Liste — Name, wofür er steht, woher der Wert kommt. Ein
|
||||||
|
Klick setzt ihn an die Cursorstelle; niemand muss `{{…}}`-Namen raten
|
||||||
|
oder abtippen. Verfügbar sind unter anderem Name und Geburtsdatum der
|
||||||
|
betreuten Person, ihre Anschrift, Betreuungsgericht und Aktenzeichen,
|
||||||
|
das Bestellungsdatum, der Stichtag und das heutige Datum.
|
||||||
|
|
||||||
|
- **Vier mitgelieferte Vorlagen** als Ausgangspunkt: Auskunftsersuchen an
|
||||||
|
eine noch unbekannte Stelle, Adressänderung nach Heimeinzug, Nachweis
|
||||||
|
der Vertretungsmacht (wenn eine Stelle die Auskunft verweigert) und die
|
||||||
|
Kurzfassung der Rückkaufswert-Anfrage. Sie lassen sich nicht kaputt
|
||||||
|
bearbeiten — „Als Kopie übernehmen" legt eine eigene Fassung an, das
|
||||||
|
Original bleibt liegen. Für Rückkaufswert und Kündigung führt die
|
||||||
|
Oberfläche weiter zuerst zu den fallbezogenen Erzeugern: Die rechnen
|
||||||
|
Kündigungstermine aus, was eine Vorlage nicht kann.
|
||||||
|
|
||||||
|
### Zwei Zusagen
|
||||||
|
|
||||||
|
**Nichts geht stillschweigend schief.** Fehlt ein Wert (etwa das
|
||||||
|
Aktenzeichen), steht im Brief eine sichtbare Lücke `[Aktenzeichen des
|
||||||
|
Gerichts]` — kein Leerraum, den erst der Empfänger bemerkt. Ein
|
||||||
|
Tippfehler im Platzhalternamen bricht nicht ab, sondern wird im Text
|
||||||
|
markiert und über dem Editor benannt.
|
||||||
|
|
||||||
|
**Der Bestellungsabsatz wird nicht vergessen.** Ohne den Nachweis der
|
||||||
|
Vertretungsmacht kommt keine Auskunft — und das fällt erst nach vier
|
||||||
|
Wochen auf. Beim Speichern einer Vorlage ohne Bestellungsabsatz kommt
|
||||||
|
deshalb eine Rückfrage. Wer ihn bewusst weglässt (etwa für die zweite
|
||||||
|
Mail an eine Stelle, die den Nachweis schon hat), bestätigt einmal und
|
||||||
|
wird nicht weiter gefragt. Ein ausformulierter Absatz zählt genauso wie
|
||||||
|
der Platzhalter.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Gelöschte Vorlagen liegen wie alles andere **30 Tage im Papierkorb**
|
||||||
|
und lassen sich zurückholen. Beim Vollexport der Akte sind sie mit
|
||||||
|
dabei.
|
||||||
|
|
||||||
|
### Geprüft
|
||||||
|
|
||||||
|
- Dreizehn Einheitstests für die Platzhalter-Ersetzung, darunter jeder
|
||||||
|
der drei Fälle (Wert einsetzen, sichtbare Lücke, unbekannter
|
||||||
|
Platzhalter) und die Bestellungsabsatz-Erkennung, die sich nicht von
|
||||||
|
einem halben Nachweis („Beschluss der Eigentümerversammlung") täuschen
|
||||||
|
lässt.
|
||||||
|
- Sechs End-to-End-Tests: die zweistufige Warnung, das Übernehmen einer
|
||||||
|
mitgelieferten Vorlage, die Auflösung im Editor samt markierter
|
||||||
|
Tippfehler und der Weg durch den Papierkorb.
|
||||||
|
|
||||||
## [1.6.0] – 2026-08-23
|
## [1.6.0] – 2026-08-23
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
@@ -868,7 +928,8 @@ 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.7.0...HEAD
|
||||||
|
[1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
|
||||||
[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0
|
[1.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
|
||||||
|
|||||||
@@ -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.7.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
ls docker-compose.yml Dockerfile .env.example # alle drei da
|
||||||
git describe --tags # v1.6.0
|
git describe --tags # v1.7.0
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```bash
|
```bash
|
||||||
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
|
||||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.6.0","commit":"..."}
|
# {"status":"bereit","version":"1.7.0","commit":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, sieh in die Logs:
|
Kommt hier nichts, sieh in die Logs:
|
||||||
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://10.0.0.5:3000/api/gesundheit
|
curl -s http://10.0.0.5:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.6.0",...}
|
# {"status":"bereit","version":"1.7.0",...}
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
||||||
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
|
|||||||
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
|
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
|
||||||
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
|
||||||
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
- Unter **Protokoll** siehst du deine eigene Anmeldung.
|
||||||
- Unten links in der Seitenleiste steht `Version 1.6.0 · <commit>`.
|
- Unten links in der Seitenleiste steht `Version 1.7.0 · <commit>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
|
|||||||
| Aufruf | Wirkung |
|
| Aufruf | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
|
||||||
| `./scripts/update.sh v1.6.0` | auf eine bestimmte Fassung |
|
| `./scripts/update.sh v1.7.0` | auf eine bestimmte Fassung |
|
||||||
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
|
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
|
||||||
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
|
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
|
||||||
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
|
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
|
||||||
@@ -913,7 +913,7 @@ git fetch --tags
|
|||||||
git log --oneline HEAD..origin/main
|
git log --oneline HEAD..origin/main
|
||||||
|
|
||||||
# 3. Umschalten
|
# 3. Umschalten
|
||||||
git checkout v1.6.0
|
git checkout v1.7.0
|
||||||
|
|
||||||
# 4. Neu bauen und starten
|
# 4. Neu bauen und starten
|
||||||
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
|
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
|
||||||
|
|||||||
+1
-1
@@ -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.7.0** ([CHANGELOG](../CHANGELOG.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betreuungs-cockpit",
|
"name": "betreuungs-cockpit",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Eigene Schreibvorlagen.
|
||||||
|
--
|
||||||
|
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
|
||||||
|
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt.
|
||||||
|
-- Wer die naechste Migration erzeugt: nachsehen.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "schreibvorlage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"betreuteId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"betreff" TEXT NOT NULL DEFAULT '',
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"ohneBestellungsabsatz" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "schreibvorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "schreibvorlage_betreuteId_idx" ON "schreibvorlage"("betreuteId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "schreibvorlage" ADD CONSTRAINT "schreibvorlage_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -313,6 +313,7 @@ model Betreute {
|
|||||||
konten Konto[]
|
konten Konto[]
|
||||||
buchungen Buchung[]
|
buchungen Buchung[]
|
||||||
zahlungsgruppen ZahlungsgruppeGeklaert[]
|
zahlungsgruppen ZahlungsgruppeGeklaert[]
|
||||||
|
schreibvorlagen Schreibvorlage[]
|
||||||
vermoegensposten Vermoegensposten[]
|
vermoegensposten Vermoegensposten[]
|
||||||
immobilien Immobilie[]
|
immobilien Immobilie[]
|
||||||
vertraege Vertrag[]
|
vertraege Vertrag[]
|
||||||
@@ -842,6 +843,39 @@ enum BuchungKategorie {
|
|||||||
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
|
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
|
||||||
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
|
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
|
||||||
/// zugeschnitten ist.
|
/// zugeschnitten ist.
|
||||||
|
/// Eine eigene Schreibvorlage.
|
||||||
|
///
|
||||||
|
/// Die mitgelieferten Vorlagen (Rueckkaufswert, Kuendigung,
|
||||||
|
/// Sachstandsanfrage) stehen bewusst NICHT in dieser Tabelle - sie sind
|
||||||
|
/// Code, getestet und unveraenderlich. "Wiederherstellen" heisst: die
|
||||||
|
/// mitgelieferte Fassung als neue Kopie hierher holen.
|
||||||
|
///
|
||||||
|
/// Der Text traegt Platzhalter im Format {{name}} - aufgeloest in
|
||||||
|
/// src/lib/schreiben/platzhalter.ts. Er ist bewusst NICHT verschluesselt:
|
||||||
|
/// Eine Vorlage ist ein Textbaustein ohne Fallbezug; die Fachdaten kommen
|
||||||
|
/// erst beim Erzeugen hinein.
|
||||||
|
model Schreibvorlage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
betreuteId String
|
||||||
|
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
betreff String @default("")
|
||||||
|
text String
|
||||||
|
|
||||||
|
/// Beim Speichern bestaetigt, dass der fehlende Bestellungsabsatz
|
||||||
|
/// Absicht ist - dann wird nicht bei jedem Bearbeiten erneut gewarnt.
|
||||||
|
ohneBestellungsabsatz Boolean @default(false)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
geloeschtAm DateTime?
|
||||||
|
|
||||||
|
@@index([betreuteId])
|
||||||
|
@@map("schreibvorlage")
|
||||||
|
}
|
||||||
|
|
||||||
model ZahlungsgruppeGeklaert {
|
model ZahlungsgruppeGeklaert {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,7 +111,15 @@ 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={{
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Feld } from '@/components/feld'
|
||||||
|
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||||
|
import {
|
||||||
|
vorlageLoeschen,
|
||||||
|
vorlageSpeichern,
|
||||||
|
type VorlagenZustand,
|
||||||
|
} from '@/server/actions/vorlagen'
|
||||||
|
import { PLATZHALTER, benutztePlatzhalter } from '@/lib/schreiben/platzhalter'
|
||||||
|
|
||||||
|
export type VorlagenZeile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MitgelieferteVorlage = {
|
||||||
|
schluessel: string
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltung der eigenen Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Zwei Listen, ein Editor. Die mitgelieferten Vorlagen sind Code und
|
||||||
|
* unveränderlich — von ihnen führt nur ein Weg hierher: „Als Kopie
|
||||||
|
* übernehmen". Damit kann nichts kaputtgehen, was die Anwendung braucht,
|
||||||
|
* und „wiederherstellen" ist schlicht: noch einmal kopieren.
|
||||||
|
*/
|
||||||
|
export function VorlagenVerwaltung({
|
||||||
|
betreuteId,
|
||||||
|
eigene,
|
||||||
|
mitgelieferte,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
eigene: VorlagenZeile[]
|
||||||
|
mitgelieferte: MitgelieferteVorlage[]
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = React.useState(false)
|
||||||
|
const [bearbeitet, setBearbeitet] = React.useState<VorlagenZeile | null>(null)
|
||||||
|
const [name, setName] = React.useState('')
|
||||||
|
const [betreff, setBetreff] = React.useState('')
|
||||||
|
const [text, setText] = React.useState('')
|
||||||
|
|
||||||
|
function neuBeginnen(vorlage?: Partial<VorlagenZeile>) {
|
||||||
|
setBearbeitet(null)
|
||||||
|
setName(vorlage?.name ?? '')
|
||||||
|
setBetreff(vorlage?.betreff ?? '')
|
||||||
|
setText(vorlage?.text ?? '')
|
||||||
|
setOffen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearbeiten(vorlage: VorlagenZeile) {
|
||||||
|
setBearbeitet(vorlage)
|
||||||
|
setName(vorlage.name)
|
||||||
|
setBetreff(vorlage.betreff)
|
||||||
|
setText(vorlage.text)
|
||||||
|
setOffen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* --- Eigene Vorlagen ---------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Eigene Vorlagen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Formulierungen, die sich bewährt haben — einmal hinterlegt, beim Schreiben wählbar.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{eigene.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Noch keine eigene Vorlage. Fang mit einer Kopie einer mitgelieferten an — oder mit
|
||||||
|
einem leeren Blatt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{eigene.map((vorlage) => (
|
||||||
|
<li key={vorlage.id} className="flex flex-wrap items-center gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{vorlage.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{benutztePlatzhalter(vorlage.text).length} Platzhalter
|
||||||
|
{vorlage.betreff ? ` · Betreff: ${vorlage.betreff}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => bearbeiten(vorlage)}>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<VorlageLoeschKnopf betreuteId={betreuteId} vorlage={vorlage} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" onClick={() => neuBeginnen()}>
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Neue Vorlage
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Mitgelieferte ------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Mitgelieferte Vorlagen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Unveränderlich — sie sind Teil der Anwendung und lassen sich nicht kaputtmachen. Als
|
||||||
|
Grundlage für eigene Fassungen: kopieren und anpassen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{mitgelieferte.map((vorlage) => (
|
||||||
|
<li key={vorlage.schluessel} className="flex flex-wrap items-center gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{vorlage.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{vorlage.beschreibung}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">mitgeliefert</Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
neuBeginnen({
|
||||||
|
name: `${vorlage.name} (eigene Fassung)`,
|
||||||
|
betreff: vorlage.betreff,
|
||||||
|
text: vorlage.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" />
|
||||||
|
Als Kopie übernehmen
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Editor --------------------------------------------------------- */}
|
||||||
|
{offen && (
|
||||||
|
<VorlagenEditor
|
||||||
|
betreuteId={betreuteId}
|
||||||
|
vorlageId={bearbeitet?.id ?? ''}
|
||||||
|
name={name}
|
||||||
|
betreff={betreff}
|
||||||
|
text={text}
|
||||||
|
onName={setName}
|
||||||
|
onBetreff={setBetreff}
|
||||||
|
onText={setText}
|
||||||
|
onSchliessen={() => setOffen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VorlageLoeschKnopf({
|
||||||
|
betreuteId,
|
||||||
|
vorlage,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
vorlage: VorlagenZeile
|
||||||
|
}) {
|
||||||
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageLoeschen, {})
|
||||||
|
const [gefragt, setGefragt] = React.useState(false)
|
||||||
|
|
||||||
|
if (!gefragt) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{zustand.fehler && <span className="text-xs text-destructive">{zustand.fehler}</span>}
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(true)}>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={absenden} className="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
<input type="hidden" name="vorlageId" value={vorlage.id} />
|
||||||
|
<Absendeknopf variant="destructive" size="sm" wartetext="…">
|
||||||
|
In den Papierkorb
|
||||||
|
</Absendeknopf>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Editor mit der Platzhalterliste daneben.
|
||||||
|
*
|
||||||
|
* Die Warnung zum Bestellungsabsatz kommt vom Server (zweistufig): Der
|
||||||
|
* erste Speicherversuch ohne ihn liefert die Warnung zurück, erst die
|
||||||
|
* Bestätigung speichert wirklich. So bleibt die Prüfung an EINER Stelle —
|
||||||
|
* eine zweite Prüfung im Browser könnte von der Serverfassung abweichen.
|
||||||
|
*/
|
||||||
|
function VorlagenEditor({
|
||||||
|
betreuteId,
|
||||||
|
vorlageId,
|
||||||
|
name,
|
||||||
|
betreff,
|
||||||
|
text,
|
||||||
|
onName,
|
||||||
|
onBetreff,
|
||||||
|
onText,
|
||||||
|
onSchliessen,
|
||||||
|
}: {
|
||||||
|
betreuteId: string
|
||||||
|
vorlageId: string
|
||||||
|
name: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
onName: (wert: string) => void
|
||||||
|
onBetreff: (wert: string) => void
|
||||||
|
onText: (wert: string) => void
|
||||||
|
onSchliessen: () => void
|
||||||
|
}) {
|
||||||
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageSpeichern, {})
|
||||||
|
const [trotzdem, setTrotzdem] = React.useState(false)
|
||||||
|
|
||||||
|
const textfeld = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
function platzhalterEinfuegen(platzhalterName: string) {
|
||||||
|
const feld = textfeld.current
|
||||||
|
const einschub = `{{${platzhalterName}}}`
|
||||||
|
if (!feld) {
|
||||||
|
onText(`${text}${einschub}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = feld.selectionStart ?? text.length
|
||||||
|
const ende = feld.selectionEnd ?? text.length
|
||||||
|
onText(`${text.slice(0, start)}${einschub}${text.slice(ende)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{vorlageId ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={absenden} className="grid gap-6 lg:grid-cols-[1fr_260px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||||
|
<input type="hidden" name="vorlageId" value={vorlageId} />
|
||||||
|
<input type="hidden" name="ohneBestellungsabsatz" value={trotzdem ? 'ja' : ''} />
|
||||||
|
|
||||||
|
{zustand.fehler && (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zustand.warnung && (
|
||||||
|
<Alert variant="warnung">
|
||||||
|
<AlertTriangle aria-hidden="true" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{zustand.warnung}</p>
|
||||||
|
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trotzdem}
|
||||||
|
onChange={(ereignis) => setTrotzdem(ereignis.target.checked)}
|
||||||
|
/>
|
||||||
|
Bewusst ohne Bestellungsabsatz speichern
|
||||||
|
</label>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zustand.erfolg && (
|
||||||
|
<Alert variant="erfolg">
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Feld id="name" beschriftung="Name der Vorlage" fehler={zustand.felder?.name} pflicht>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(ereignis) => onName(ereignis.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Auskunftsersuchen Krankenkasse"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="betreff"
|
||||||
|
beschriftung="Betreff"
|
||||||
|
erklaerung="Darf Platzhalter enthalten. Wird beim Erzeugen zum Betreff des Schreibens."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="betreff"
|
||||||
|
value={betreff}
|
||||||
|
onChange={(ereignis) => onBetreff(ereignis.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld id="text" beschriftung="Text der Vorlage" fehler={zustand.felder?.text} pflicht>
|
||||||
|
<Textarea
|
||||||
|
ref={textfeld}
|
||||||
|
name="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(ereignis) => onText(ereignis.target.value)}
|
||||||
|
rows={18}
|
||||||
|
className="font-mono text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Absendeknopf wartetext="Wird gespeichert …">Speichern</Absendeknopf>
|
||||||
|
<Button type="button" variant="ghost" onClick={onSchliessen}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die Platzhalterliste - niemand soll Namen raten muessen. */}
|
||||||
|
<aside className="space-y-2 rounded-md border p-3">
|
||||||
|
<p className="text-sm font-medium">Platzhalter</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Klick fügt an der Schreibmarke ein. Fehlt beim Erzeugen der Wert, bleibt eine
|
||||||
|
sichtbare Lücke — nichts verschwindet still.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{PLATZHALTER.map((platzhalter) => (
|
||||||
|
<li key={platzhalter.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => platzhalterEinfuegen(platzhalter.name)}
|
||||||
|
className="w-full rounded px-2 py-1 text-left text-xs hover:bg-accent"
|
||||||
|
title={platzhalter.quelle}
|
||||||
|
>
|
||||||
|
<code className="text-primary">{'{{'}{platzhalter.name}{'}}'}</code>
|
||||||
|
<span className="block text-muted-foreground">
|
||||||
|
{platzhalter.beschriftung}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -230,7 +234,64 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Freies Schreiben ------------------------------------------------------
|
// --- Freies Schreiben, wahlweise aus eigener Vorlage -----------------------
|
||||||
|
if (eingabe.vorlageId) {
|
||||||
|
const vorlage = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: eingabe.vorlageId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!vorlage) throw new Error('Diese Vorlage gibt es nicht (mehr).')
|
||||||
|
|
||||||
|
// Die Werte, die eine Vorlage kennen kann. Vertragsbezogene bleiben
|
||||||
|
// hier leer und erscheinen als sichtbare Luecke - ein freies Schreiben
|
||||||
|
// hat keinen Vertrag.
|
||||||
|
const werte: PlatzhalterWerte = {
|
||||||
|
betreute_name: kopf.betreuteName,
|
||||||
|
betreute_geburtsdatum: kopf.betreuteGeburtsdatum
|
||||||
|
? formatDatum(kopf.betreuteGeburtsdatum)
|
||||||
|
: null,
|
||||||
|
betreute_anschrift: kopf.betreuteAnschrift,
|
||||||
|
betreuer_name: kopf.betreuerName,
|
||||||
|
betreuungsgericht: kopf.betreuungsgericht,
|
||||||
|
aktenzeichen_gericht: kopf.aktenzeichenGericht,
|
||||||
|
bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null,
|
||||||
|
bestellungsabsatz: bestellungsAbsatz(kopf),
|
||||||
|
stichtag: formatDatum(heute),
|
||||||
|
datum_heute: formatDatum(heute),
|
||||||
|
}
|
||||||
|
|
||||||
|
const rumpf = platzhalterErsetzen(vorlage.text, werte)
|
||||||
|
const betreffErsetzt = platzhalterErsetzen(vorlage.betreff, werte)
|
||||||
|
|
||||||
|
// Der Briefrahmen kommt dazu - die Vorlage traegt nur den Inhalt.
|
||||||
|
const text = [
|
||||||
|
absenderBlock(kopf),
|
||||||
|
'',
|
||||||
|
anschriftBlock({ gesellschaft: null, anschrift: null }),
|
||||||
|
'',
|
||||||
|
datumsZeile(kopf.betreuerOrt, heute),
|
||||||
|
'',
|
||||||
|
betreffErsetzt.text ? `Betreff: ${betreffErsetzt.text}` : 'Betreff: [Betreff eintragen]',
|
||||||
|
'',
|
||||||
|
rumpf.text,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gemeinsam,
|
||||||
|
text,
|
||||||
|
betreff: betreffErsetzt.text,
|
||||||
|
empfaengerName: '',
|
||||||
|
empfaengerAnschrift: '',
|
||||||
|
warnungen:
|
||||||
|
rumpf.unbekannte.length > 0
|
||||||
|
? [
|
||||||
|
`Die Vorlage enthält unbekannte Platzhalter: ${rumpf.unbekannte
|
||||||
|
.map((name) => `{{${name}}}`)
|
||||||
|
.join(', ')}. Sie sind im Text markiert - bitte vor dem Absenden ersetzen.`,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
|
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
|
||||||
return {
|
return {
|
||||||
...gemeinsam,
|
...gemeinsam,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Die mitgelieferten Schreibvorlagen - als Platzhalter-Fassung.
|
||||||
|
*
|
||||||
|
* Sie sind Code und damit unveraenderlich; "wiederherstellen" heisst,
|
||||||
|
* sie erneut als Kopie in die eigenen Vorlagen zu holen.
|
||||||
|
*
|
||||||
|
* Bewusst NICHT dieselben Texte wie die fest verdrahteten Erzeuger in
|
||||||
|
* rueckkaufswert.ts und kuendigungsassistent.ts: Die dortigen bauen
|
||||||
|
* fallbezogene Saetze (Kuendigungstermin, Warnungen), die eine Vorlage
|
||||||
|
* nicht leisten kann. Diese hier sind der gemeinsame Kern als
|
||||||
|
* Ausgangspunkt fuer eigene Fassungen - die Spezialerzeuger bleiben der
|
||||||
|
* bessere Weg, wo es sie gibt, und die Oberflaeche fuehrt weiter zuerst
|
||||||
|
* dorthin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MitgelieferteVorlage = {
|
||||||
|
schluessel: string
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MITGELIEFERTE_VORLAGEN: readonly MitgelieferteVorlage[] = [
|
||||||
|
{
|
||||||
|
schluessel: 'auskunftsersuchen',
|
||||||
|
name: 'Auskunftsersuchen',
|
||||||
|
beschreibung: 'Bestehen bei Ihnen Verträge? Der erste Brief an eine unbekannte Stelle.',
|
||||||
|
betreff: 'Auskunftsersuchen — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'zur Ordnung der Vermögensangelegenheiten bitte ich um Auskunft, ob bei',
|
||||||
|
'Ihnen Verträge, Konten oder sonstige Geschäftsbeziehungen für die von mir',
|
||||||
|
'vertretene Person bestehen oder in den letzten Jahren bestanden haben.',
|
||||||
|
'',
|
||||||
|
'Falls ja, bitte ich um eine Aufstellung mit Vertragsnummern, aktuellem',
|
||||||
|
'Stand und den jährlichen Beiträgen bzw. Leistungen.',
|
||||||
|
'',
|
||||||
|
'Für Rückfragen stehe ich gerne zur Verfügung.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'adressaenderung',
|
||||||
|
name: 'Adressänderung',
|
||||||
|
beschreibung: 'Neue Anschrift nach Heimeinzug mitteilen.',
|
||||||
|
betreff: 'Anschriftenänderung — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'die Anschrift der von mir vertretenen Person hat sich geändert. Die neue',
|
||||||
|
'Anschrift lautet:',
|
||||||
|
'',
|
||||||
|
'{{betreute_anschrift}}',
|
||||||
|
'',
|
||||||
|
'Bitte stellen Sie künftige Post zu dieser Sache außerdem an mich als',
|
||||||
|
'rechtlichen Betreuer zu.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'vollmachtsnachweis',
|
||||||
|
name: 'Nachweis der Vertretungsmacht',
|
||||||
|
beschreibung: 'Wenn eine Stelle die Auskunft verweigert: der Nachweis, förmlich.',
|
||||||
|
betreff: 'Nachweis der Vertretungsmacht — {{betreute_name}}, geb. {{betreute_geburtsdatum}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'auf Ihr Schreiben, in dem Sie um einen Nachweis meiner Vertretungsmacht',
|
||||||
|
'bitten, teile ich mit:',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'Eine Kopie der Bestellungsurkunde liegt bei. Das Original stelle ich bei',
|
||||||
|
'Bedarf zur Einsicht — bitte nennen Sie mir dafür einen Termin.',
|
||||||
|
'',
|
||||||
|
'Ich bitte nunmehr um Bearbeitung meines Anliegens vom {{datum_heute}}.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: 'rueckkaufswert_kurz',
|
||||||
|
name: 'Rückkaufswert (Kurzfassung)',
|
||||||
|
beschreibung:
|
||||||
|
'Die Kurzfassung der Rückkaufswert-Anfrage. Für den vollen Fragenkatalog nimm den Weg über den Vertrag.',
|
||||||
|
betreff:
|
||||||
|
'Auskunft über Rückkaufswert — {{vertrag_bezeichnung}}, Nr. {{vertrag_nummer}}',
|
||||||
|
text: [
|
||||||
|
'Sehr geehrte Damen und Herren,',
|
||||||
|
'',
|
||||||
|
'{{bestellungsabsatz}}',
|
||||||
|
'',
|
||||||
|
'zu dem oben genannten Vertrag bei {{vertrag_gesellschaft}} bitte ich um',
|
||||||
|
'Mitteilung des Rückkaufswertes einschließlich Überschussanteilen zum',
|
||||||
|
'Stichtag {{stichtag}}, außerdem der beitragsfreien Versicherungssumme.',
|
||||||
|
'',
|
||||||
|
'Da im Verfahren beim Sozialhilfeträger eine Frist läuft, bitte ich um',
|
||||||
|
'Antwort innerhalb von vier Wochen.',
|
||||||
|
'',
|
||||||
|
'Mit freundlichen Grüßen',
|
||||||
|
'{{betreuer_name}}',
|
||||||
|
'',
|
||||||
|
'Anlage: Kopie der Bestellungsurkunde',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Platzhalter in Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Rein und ohne Datenbank. Der Vertrag ist einfach: {{name}} wird ersetzt,
|
||||||
|
* und zwar nach drei Regeln, die alle demselben Grundsatz folgen -
|
||||||
|
* NICHTS geht stillschweigend schief:
|
||||||
|
*
|
||||||
|
* 1. Bekannter Platzhalter, Daten vorhanden -> Wert eingesetzt.
|
||||||
|
* 2. Bekannter Platzhalter, Daten fehlen -> sichtbare Luecke
|
||||||
|
* "[Aktenzeichen]". Eine stille Leerstelle wuerde erst der Empfaenger
|
||||||
|
* bemerken - oder niemand.
|
||||||
|
* 3. Unbekannter Platzhalter -> markiert als
|
||||||
|
* "[Unbekannter Platzhalter: {{xyz}}]". Kein Abbruch: Ein Tippfehler
|
||||||
|
* in einer Vorlage darf das Schreiben nicht verhindern, aber er muss
|
||||||
|
* ins Auge springen. Falsch ausgefuellt ist schlimmer als
|
||||||
|
* offensichtlich leer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlatzhalterName =
|
||||||
|
| 'betreute_name'
|
||||||
|
| 'betreute_geburtsdatum'
|
||||||
|
| 'betreute_anschrift'
|
||||||
|
| 'betreuer_name'
|
||||||
|
| 'betreuungsgericht'
|
||||||
|
| 'aktenzeichen_gericht'
|
||||||
|
| 'bestellung_datum'
|
||||||
|
| 'bestellungsabsatz'
|
||||||
|
| 'vertrag_gesellschaft'
|
||||||
|
| 'vertrag_nummer'
|
||||||
|
| 'vertrag_bezeichnung'
|
||||||
|
| 'stichtag'
|
||||||
|
| 'datum_heute'
|
||||||
|
|
||||||
|
export type PlatzhalterBeschreibung = {
|
||||||
|
name: PlatzhalterName
|
||||||
|
beschriftung: string
|
||||||
|
/** Woher der Wert kommt - steht in der Hilfe neben dem Editor. */
|
||||||
|
quelle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die verfuegbaren Platzhalter, in der Reihenfolge der Anzeige.
|
||||||
|
*
|
||||||
|
* Diese Liste steht beim Bearbeiten NEBEN dem Editor - niemand soll
|
||||||
|
* Platzhalternamen raten muessen.
|
||||||
|
*/
|
||||||
|
export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
|
||||||
|
{ name: 'betreute_name', beschriftung: 'Name der betreuten Person', quelle: 'Stammdaten' },
|
||||||
|
{ name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' },
|
||||||
|
{ name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' },
|
||||||
|
{ name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' },
|
||||||
|
{ name: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' },
|
||||||
|
{ name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' },
|
||||||
|
{ name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', quelle: 'Stammdaten' },
|
||||||
|
{
|
||||||
|
name: 'bestellungsabsatz',
|
||||||
|
beschriftung: 'Der ganze Bestellungsabsatz',
|
||||||
|
quelle: 'Aus Gericht, Aktenzeichen und Bestellungsdatum gebaut',
|
||||||
|
},
|
||||||
|
{ name: 'vertrag_gesellschaft', beschriftung: 'Gesellschaft', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'vertrag_nummer', beschriftung: 'Vertragsnummer', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'vertrag_bezeichnung', beschriftung: 'Vertragsbezeichnung', quelle: 'Vertrag, falls gewählt' },
|
||||||
|
{ name: 'stichtag', beschriftung: 'Stichtag', quelle: 'Beim Erzeugen gewählt' },
|
||||||
|
{ name: 'datum_heute', beschriftung: 'Heutiges Datum', quelle: 'Beim Erzeugen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BEKANNT = new Set<string>(PLATZHALTER.map((p) => p.name))
|
||||||
|
|
||||||
|
/** Die sichtbare Luecke, wenn Daten fehlen: "[Aktenzeichen des Gerichts]". */
|
||||||
|
function luecke(name: PlatzhalterName): string {
|
||||||
|
const eintrag = PLATZHALTER.find((p) => p.name === name)
|
||||||
|
return `[${eintrag?.beschriftung ?? name}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatzhalterWerte = Partial<Record<PlatzhalterName, string | null>>
|
||||||
|
|
||||||
|
export type ErsetzungsErgebnis = {
|
||||||
|
text: string
|
||||||
|
/** Bekannte Platzhalter ohne Daten - als Luecke eingesetzt. */
|
||||||
|
luecken: PlatzhalterName[]
|
||||||
|
/** Unbekannte Platzhalter - markiert im Text. */
|
||||||
|
unbekannte: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet alle {{...}}-Vorkommen. Grosszuegig beim Namen: auch Tippfehler
|
||||||
|
* sollen gefunden (und dann markiert) werden. */
|
||||||
|
const MUSTER = /\{\{\s*([a-zA-Z0-9_äöüÄÖÜß-]+)\s*\}\}/g
|
||||||
|
|
||||||
|
export function platzhalterErsetzen(
|
||||||
|
vorlage: string,
|
||||||
|
werte: PlatzhalterWerte,
|
||||||
|
): ErsetzungsErgebnis {
|
||||||
|
const luecken = new Set<PlatzhalterName>()
|
||||||
|
const unbekannte = new Set<string>()
|
||||||
|
|
||||||
|
const text = vorlage.replace(MUSTER, (treffer, roh: string) => {
|
||||||
|
const name = roh.trim()
|
||||||
|
|
||||||
|
if (!BEKANNT.has(name)) {
|
||||||
|
unbekannte.add(name)
|
||||||
|
return `[Unbekannter Platzhalter: {{${name}}}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const wert = werte[name as PlatzhalterName]
|
||||||
|
if (wert === null || wert === undefined || wert.trim() === '') {
|
||||||
|
luecken.add(name as PlatzhalterName)
|
||||||
|
return luecke(name as PlatzhalterName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wert
|
||||||
|
})
|
||||||
|
|
||||||
|
return { text, luecken: [...luecken], unbekannte: [...unbekannte] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Platzhalter benutzt eine Vorlage? Fuer die Anzeige in der Liste. */
|
||||||
|
export function benutztePlatzhalter(vorlage: string): string[] {
|
||||||
|
const gefunden = new Set<string>()
|
||||||
|
for (const treffer of vorlage.matchAll(MUSTER)) {
|
||||||
|
gefunden.add(treffer[1]!.trim())
|
||||||
|
}
|
||||||
|
return [...gefunden]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlt der Bestellungsabsatz?
|
||||||
|
*
|
||||||
|
* Erfuellt ist die Pruefung durch den Platzhalter {{bestellungsabsatz}}
|
||||||
|
* ODER durch einen von Hand geschriebenen Absatz, der erkennbar die
|
||||||
|
* Bestellung nachweist (Beschluss + Betreuer im selben Text). Wer den
|
||||||
|
* Absatz ausformuliert hat, soll nicht gegaengelt werden.
|
||||||
|
*
|
||||||
|
* Die Warnung ist eine Warnung, kein Verbot: Ein Schreiben an die eigene
|
||||||
|
* Hausverwaltung braucht keinen Bestellungsnachweis. Aber wer ihn
|
||||||
|
* weglaesst, soll es wissentlich tun.
|
||||||
|
*/
|
||||||
|
export function bestellungsabsatzFehlt(vorlage: string): boolean {
|
||||||
|
if (/\{\{\s*bestellungsabsatz\s*\}\}/.test(vorlage)) return false
|
||||||
|
const klein = vorlage.toLowerCase()
|
||||||
|
const nennBestellung =
|
||||||
|
klein.includes('beschluss') && (klein.includes('betreuer') || klein.includes('betreuung'))
|
||||||
|
return !nennBestellung
|
||||||
|
}
|
||||||
+1
-1
@@ -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.7.0'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
* Kurzer Git-Stand, beim Bauen hineingereicht:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const PAPIERKORB_TABELLEN = [
|
|||||||
'bescheid',
|
'bescheid',
|
||||||
'leistung',
|
'leistung',
|
||||||
'bericht',
|
'bericht',
|
||||||
|
'schreibvorlage',
|
||||||
'betreute',
|
'betreute',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
|
import { protokollieren, aenderungErmitteln } from '@/lib/audit'
|
||||||
|
import { bestellungsabsatzFehlt } from '@/lib/schreiben/platzhalter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions fuer eigene Schreibvorlagen.
|
||||||
|
*
|
||||||
|
* Die Warnung beim fehlenden Bestellungsabsatz ist zweistufig: Der erste
|
||||||
|
* Speicherversuch ohne ihn kommt mit einer Warnung zurueck; erst die
|
||||||
|
* ausdrueckliche Bestaetigung ("ohneBestellungsabsatz") speichert. So
|
||||||
|
* laesst sich der Absatz weglassen - aber nur wissentlich. Ohne den
|
||||||
|
* Nachweis der Bestellung antwortet keine Versicherung, und das faellt
|
||||||
|
* erst nach vier Wochen auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VorlagenZustand = {
|
||||||
|
fehler?: string
|
||||||
|
/** Die Bestellungsabsatz-Warnung - Speichern erst nach Bestaetigung. */
|
||||||
|
warnung?: string
|
||||||
|
erfolg?: string
|
||||||
|
felder?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function zugriffsfehler(ausnahme: unknown): VorlagenZustand | null {
|
||||||
|
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorlageSchema = z.object({
|
||||||
|
betreuteId: z.string().min(1),
|
||||||
|
vorlageId: z.string().trim().default(''),
|
||||||
|
name: z.string().trim().min(1, 'Bitte einen Namen vergeben.').max(120),
|
||||||
|
betreff: z.string().trim().max(200).default(''),
|
||||||
|
text: z.string().trim().min(10, 'Der Text ist zu kurz für eine Vorlage.').max(50_000),
|
||||||
|
ohneBestellungsabsatz: z.string().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function vorlageSpeichern(
|
||||||
|
_zustand: VorlagenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<VorlagenZustand> {
|
||||||
|
const gelesen = vorlageSchema.safeParse({
|
||||||
|
betreuteId: daten.get('betreuteId'),
|
||||||
|
vorlageId: daten.get('vorlageId') ?? '',
|
||||||
|
name: daten.get('name'),
|
||||||
|
betreff: daten.get('betreff') ?? '',
|
||||||
|
text: daten.get('text'),
|
||||||
|
ohneBestellungsabsatz: daten.get('ohneBestellungsabsatz') ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder = gelesen.error.flatten().fieldErrors
|
||||||
|
return {
|
||||||
|
fehler: 'Bitte prüfe die Eingaben.',
|
||||||
|
felder: Object.fromEntries(
|
||||||
|
Object.entries(felder).map(([name, meldungen]) => [name, meldungen?.[0] ?? '']),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = gelesen.data
|
||||||
|
const bestaetigt = werte.ohneBestellungsabsatz === 'ja'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(werte.betreuteId, 'DOKUMENTE', 'schreiben')
|
||||||
|
|
||||||
|
// Die zweistufige Warnung: erst warnen, dann - bestaetigt - speichern.
|
||||||
|
if (bestellungsabsatzFehlt(werte.text) && !bestaetigt) {
|
||||||
|
return {
|
||||||
|
warnung:
|
||||||
|
'In dieser Vorlage fehlt der Bestellungsabsatz — der Nachweis, dass du zur Vertretung ' +
|
||||||
|
'befugt bist. Ohne ihn antworten Versicherungen und Behörden mit „aus ' +
|
||||||
|
'datenschutzrechtlichen Gründen keine Auskunft", und das fällt erst nach Wochen auf. ' +
|
||||||
|
'Füge {{bestellungsabsatz}} ein — oder speichere bewusst ohne.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (werte.vorlageId) {
|
||||||
|
const vorhanden = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: werte.vorlageId, betreuteId: werte.betreuteId, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!vorhanden) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
const nachher = await db.schreibvorlage.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: {
|
||||||
|
name: werte.name,
|
||||||
|
betreff: werte.betreff,
|
||||||
|
text: werte.text,
|
||||||
|
ohneBestellungsabsatz: bestaetigt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'GEAENDERT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: nachher.id,
|
||||||
|
bezeichnung: nachher.name,
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
aenderung: aenderungErmitteln(
|
||||||
|
{ name: vorhanden.name, betreff: vorhanden.betreff, text: vorhanden.text },
|
||||||
|
{ name: nachher.name, betreff: nachher.betreff, text: nachher.text },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const neu = await db.schreibvorlage.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
name: werte.name,
|
||||||
|
betreff: werte.betreff,
|
||||||
|
text: werte.text,
|
||||||
|
ohneBestellungsabsatz: bestaetigt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'ERSTELLT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: neu.id,
|
||||||
|
bezeichnung: neu.name,
|
||||||
|
betreuteId: werte.betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[vorlagen] Speichern fehlgeschlagen:', ausnahme)
|
||||||
|
return { fehler: 'Die Vorlage konnte nicht gespeichert werden.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen/vorlagen')
|
||||||
|
return { erfolg: `„${werte.name}“ ist gespeichert.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Papierkorb: geloeschtAm setzen, 30 Tage rueckholbar wie ueberall. */
|
||||||
|
export async function vorlageLoeschen(
|
||||||
|
_zustand: VorlagenZustand,
|
||||||
|
daten: FormData,
|
||||||
|
): Promise<VorlagenZustand> {
|
||||||
|
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||||
|
const vorlageId = String(daten.get('vorlageId') ?? '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
|
||||||
|
|
||||||
|
const vorlage = await db.schreibvorlage.findFirst({
|
||||||
|
where: { id: vorlageId, betreuteId, ...nichtGeloescht },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!vorlage) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||||
|
|
||||||
|
await db.schreibvorlage.update({
|
||||||
|
where: { id: vorlage.id },
|
||||||
|
data: { geloeschtAm: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'GELOESCHT',
|
||||||
|
entitaet: 'Schreibvorlage',
|
||||||
|
entitaetId: vorlage.id,
|
||||||
|
bezeichnung: vorlage.name,
|
||||||
|
betreuteId,
|
||||||
|
userId: benutzer.id,
|
||||||
|
userEmail: benutzer.email,
|
||||||
|
userName: benutzer.name,
|
||||||
|
})
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const verweigert = zugriffsfehler(ausnahme)
|
||||||
|
if (verweigert) return verweigert
|
||||||
|
console.error('[vorlagen] Löschen fehlgeschlagen:', ausnahme)
|
||||||
|
return { fehler: 'Die Vorlage konnte nicht gelöscht werden.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen/vorlagen')
|
||||||
|
return { erfolg: 'In den Papierkorb gelegt — 30 Tage rückholbar.' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user