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() { + + + + +