From f8d7752c27cab6e53e46400bd556ad7529e22bc6 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 23 Aug 2026 13:41:01 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 63 ++- docs/INSTALLATION.md | 14 +- docs/ROADMAP.md | 2 +- e2e/vorlagen.spec.ts | 157 ++++++++ package.json | 2 +- .../migration.sql | 27 ++ prisma/schema.prisma | 34 ++ src/app/(app)/einstellungen/page.tsx | 19 +- src/app/(app)/einstellungen/vorlagen/page.tsx | 51 +++ src/app/(app)/schreiben/neu/page.tsx | 19 + .../einstellungen/vorlagen-verwaltung.tsx | 374 ++++++++++++++++++ src/components/schreiben/vorlagen-wahl.tsx | 57 +++ src/lib/berichte/vollexport.ts | 7 + src/lib/schreiben/entwurf.ts | 63 ++- src/lib/schreiben/mitgelieferte-vorlagen.ts | 123 ++++++ src/lib/schreiben/platzhalter.ts | 143 +++++++ src/lib/version.ts | 2 +- src/lib/wartung.ts | 1 + src/server/actions/papierkorb.ts | 8 + src/server/actions/vorlagen.ts | 187 +++++++++ tests/platzhalter.test.ts | 103 +++++ 21 files changed, 1443 insertions(+), 13 deletions(-) create mode 100644 e2e/vorlagen.spec.ts create mode 100644 prisma/migrations/20260823102500_schreibvorlage/migration.sql create mode 100644 src/app/(app)/einstellungen/vorlagen/page.tsx create mode 100644 src/components/einstellungen/vorlagen-verwaltung.tsx create mode 100644 src/components/schreiben/vorlagen-wahl.tsx create mode 100644 src/lib/schreiben/mitgelieferte-vorlagen.ts create mode 100644 src/lib/schreiben/platzhalter.ts create mode 100644 src/server/actions/vorlagen.ts create mode 100644 tests/platzhalter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4616d6b..2500845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,66 @@ Wiederherstellung*. _Noch nichts._ +## [1.7.0] – 2026-08-23 + +### Hinzugefügt + +- **Eigene Vorlagen für Anschreiben** + ([#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7)). + Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal + falsch. Neu unter **Einstellungen → Schreibvorlagen**: eigene Texte + hinterlegen, beim freien Schreiben auswählen — der Rest füllt sich aus + den Daten, die ohnehin im System stehen. + +- **Platzhalter, die sich selbst erklären.** Neben dem Editor steht die + vollständige Liste — Name, wofür er steht, woher der Wert kommt. Ein + Klick setzt ihn an die Cursorstelle; niemand muss `{{…}}`-Namen raten + oder abtippen. Verfügbar sind unter anderem Name und Geburtsdatum der + betreuten Person, ihre Anschrift, Betreuungsgericht und Aktenzeichen, + das Bestellungsdatum, der Stichtag und das heutige Datum. + +- **Vier mitgelieferte Vorlagen** als Ausgangspunkt: Auskunftsersuchen an + eine noch unbekannte Stelle, Adressänderung nach Heimeinzug, Nachweis + der Vertretungsmacht (wenn eine Stelle die Auskunft verweigert) und die + Kurzfassung der Rückkaufswert-Anfrage. Sie lassen sich nicht kaputt + bearbeiten — „Als Kopie übernehmen" legt eine eigene Fassung an, das + Original bleibt liegen. Für Rückkaufswert und Kündigung führt die + Oberfläche weiter zuerst zu den fallbezogenen Erzeugern: Die rechnen + Kündigungstermine aus, was eine Vorlage nicht kann. + +### Zwei Zusagen + +**Nichts geht stillschweigend schief.** Fehlt ein Wert (etwa das +Aktenzeichen), steht im Brief eine sichtbare Lücke `[Aktenzeichen des +Gerichts]` — kein Leerraum, den erst der Empfänger bemerkt. Ein +Tippfehler im Platzhalternamen bricht nicht ab, sondern wird im Text +markiert und über dem Editor benannt. + +**Der Bestellungsabsatz wird nicht vergessen.** Ohne den Nachweis der +Vertretungsmacht kommt keine Auskunft — und das fällt erst nach vier +Wochen auf. Beim Speichern einer Vorlage ohne Bestellungsabsatz kommt +deshalb eine Rückfrage. Wer ihn bewusst weglässt (etwa für die zweite +Mail an eine Stelle, die den Nachweis schon hat), bestätigt einmal und +wird nicht weiter gefragt. Ein ausformulierter Absatz zählt genauso wie +der Platzhalter. + +### Geändert + +- Gelöschte Vorlagen liegen wie alles andere **30 Tage im Papierkorb** + und lassen sich zurückholen. Beim Vollexport der Akte sind sie mit + dabei. + +### Geprüft + +- Dreizehn Einheitstests für die Platzhalter-Ersetzung, darunter jeder + der drei Fälle (Wert einsetzen, sichtbare Lücke, unbekannter + Platzhalter) und die Bestellungsabsatz-Erkennung, die sich nicht von + einem halben Nachweis („Beschluss der Eigentümerversammlung") täuschen + lässt. +- Sechs End-to-End-Tests: die zweistufige Warnung, das Übernehmen einer + mitgelieferten Vorlage, die Auflösung im Editor samt markierter + Tippfehler und der Weg durch den Papierkorb. + ## [1.6.0] – 2026-08-23 ### Hinzugefügt @@ -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.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 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 0dda444..86cf027 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.6.0 +git checkout v1.7.0 ``` ### Kontrolle ```bash 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 # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit -# {"status":"bereit","version":"1.6.0","commit":"..."} +# {"status":"bereit","version":"1.7.0","commit":"..."} ``` Kommt hier nichts, sieh in die Logs: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash curl -s http://10.0.0.5:3000/api/gesundheit -# {"status":"bereit","version":"1.6.0",...} +# {"status":"bereit","version":"1.7.0",...} ``` Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.6.0 · `. +- Unten links in der Seitenleiste steht `Version 1.7.0 · `. --- @@ -875,7 +875,7 @@ alles so vor wie zuvor. | Aufruf | Wirkung | |---|---| | `./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 --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.6.0 +git checkout v1.7.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1754126..8efd2fb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre. Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb als wichtiger herausstellt, rutscht nach oben. -Stand: 23.08.2026 · Aktuelle Fassung: **1.6.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.7.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/vorlagen.spec.ts b/e2e/vorlagen.spec.ts new file mode 100644 index 0000000..5a325dc --- /dev/null +++ b/e2e/vorlagen.spec.ts @@ -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(arbeit: (db: PrismaClient) => Promise): Promise { + const db = new PrismaClient() + try { + return await arbeit(db) + } finally { + await db.$disconnect() + } +} + +async function aufraeumen(): Promise { + 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() + }) +}) diff --git a/package.json b/package.json index 24cef03..0d33a4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.6.0", + "version": "1.7.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/migrations/20260823102500_schreibvorlage/migration.sql b/prisma/migrations/20260823102500_schreibvorlage/migration.sql new file mode 100644 index 0000000..a5d4b15 --- /dev/null +++ b/prisma/migrations/20260823102500_schreibvorlage/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1af8ef0..2646987 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -313,6 +313,7 @@ model Betreute { konten Konto[] buchungen Buchung[] zahlungsgruppen ZahlungsgruppeGeklaert[] + schreibvorlagen Schreibvorlage[] vermoegensposten Vermoegensposten[] immobilien Immobilie[] vertraege Vertrag[] @@ -842,6 +843,39 @@ enum BuchungKategorie { /// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere, /// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders /// 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 { id String @id @default(cuid()) diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index 4ff7acf..e741daa 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -1,5 +1,5 @@ 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 { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -69,6 +69,23 @@ export default async function EinstellungenSeite() { + + + + +