diff --git a/CHANGELOG.md b/CHANGELOG.md index b39e3b0..78da0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,74 @@ Wiederherstellung*. _Noch nichts._ +## [1.4.0] – 2026-08-23 + +### Hinzugefügt + +- **Schreiben erzeugen, ausgeben und ablegen** + ([#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4)). + Die Anschreiben an Versicherer und Behörden gab es bisher nur als Text + zum Kopieren. Danach war Handarbeit: in Word nachbauen, ausdrucken, + abschicken — und noch einmal von Hand als Dokument in die Akte legen. + Vier Schritte, und der letzte wurde am häufigsten vergessen. Dann fehlt + der Nachweis genau dort, wo er gebraucht wird. + + Neu unter **Schreiben**: Text bearbeiten, als **Word-Datei** + herunterladen, **in die Akte legen** und dabei eine **Wiedervorlage** + setzen — in einem Zug. + + Der Text bleibt vollständig bearbeitbar. Jeder Fall hat eine + Besonderheit, die in den Brief gehört; ein Formulartext, den man nicht + mehr ändern kann, wird früher oder später falsch abgeschickt. + +- **Schreiben ohne Vertrag und ohne Vorgang.** Bisher ging ein Anschreiben + nur zu einem vorhandenen Datensatz. Am Anfang einer Betreuung schreibt + man aber gerade an Stellen, zu denen es noch keinen gibt — man fragt ja + an, um zu erfahren, ob dort überhaupt etwas besteht. Jetzt geht es an + einen **Kontakt** oder an eine **frei getippte Anschrift**, aus der sich + mit einem Klick ein Kontakt anlegen lässt. So wächst das Adressbuch + nebenbei beim Schreiben statt durch Pflege. + +- **Die Wiedervorlage entsteht mit.** Rückkaufswert 28 Tage, Kündigung 21, + Sachstandsanfrage 42 — jeweils änderbar, und daneben steht, warum die + Frist so lang ist. Bei einer Sachstandsanfrage wird zusätzlich der letzte + Kontakt am Vorgang nachgezogen, damit *Wartet auf andere* auf „Heute" + stimmt. + +### Zwei Entscheidungen, die erklärt gehören + +**In die Akte kommt eine PDF-Fassung, heruntergeladen wird eine +Word-Datei.** Der Objektspeicher nimmt nur PDF und Bilder an, geprüft an +den Magic Bytes — eine Word-Datei ist ein ZIP-Archiv, sie durchzulassen +hieße jedes ZIP durchzulassen. Diese Prüfung ist eine bewusste Zusage der +Anwendung; sie für eine Bequemlichkeit aufzuweichen wäre der falsche +Handel. Und sachlich ist PDF für den Nachweis ohnehin das richtige Format: +Es ändert sich nicht mehr. + +Daraus folgt eine Einschränkung, die auch in der Oberfläche steht: +**Änderst du die Word-Datei danach noch**, weicht die Akte davon ab. Dann +lieber dort fertig schreiben, den Text zurückkopieren und erst dann +ablegen. + +**Der Berichtssatz wurde nicht umgebogen.** Die vorhandene DOCX-Ausgabe +setzt einen Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief +braucht Absender, Anschriftenfeld, Datumszeile und Grußformel. Beides in +eine Funktion zu zwingen hätte beide Ausgaben schlechter gemacht. + +### Geprüft + +- Zehn Einheitstests, darunter der Gleichschritt der Wiedervorlagefristen: + Sie stehen an zwei Stellen und dürfen nicht auseinanderlaufen, sonst + mahnt die Anwendung zu zwei verschiedenen Terminen. +- Fünf End-to-End-Tests: Entwurf mit Bestellungsabsatz, Vertragsbezug, + Word-Download, Ablage samt Wiedervorlage und Kontakt aus freier + Anschrift. + + Zwei Fehler hat erst der Browsertest gefunden — beide in den Tests + selbst: Die Dokumentenliste rendert Tabelle **und** Karten, je nach + Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die + unsichtbare Fassung sieht aus wie ein Fehler in der Anwendung. + ## [1.3.0] – 2026-08-23 ### Hinzugefügt @@ -700,7 +768,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.3.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...HEAD +[1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0 [1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0 [1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0 [1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2 diff --git a/README.md b/README.md index 38c1b28..c1db96f 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind. | | | nachzuzählen mit | |---|---:|---| -| Zeilen TypeScript/TSX | 76.216 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Zeilen TypeScript/TSX | 76.610 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | -| Seiten | 79 | `find src/app -name page.tsx \| wc -l` | -| Unit-Tests (Vitest) | 560 | `npm test` | -| End-to-End-Tests (Playwright) | 63 | `npm run test:e2e` — 35 Tests über zwei Geräteprofile, geräteabhängige übersprungen | +| Seiten | 81 | `find src/app -name page.tsx \| wc -l` | +| Unit-Tests (Vitest) | 570 | `npm test` | +| End-to-End-Tests (Playwright) | 73 | `npm run test:e2e` — 40 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | TypeScript-Fehler | 0 | `npm run typecheck` | Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 0948d3c..fe95c00 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.3.0 +git checkout v1.4.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.3.0 +git describe --tags # v1.4.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.3.0","commit":"..."} +# {"status":"bereit","version":"1.4.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.3.0",...} +# {"status":"bereit","version":"1.4.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.3.0 · `. +- Unten links in der Seitenleiste steht `Version 1.4.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.3.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.4.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.3.0 +git checkout v1.4.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 6fd8d98..594d040 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.3.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.4.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/schreiben.spec.ts b/e2e/schreiben.spec.ts new file mode 100644 index 0000000..834c59d --- /dev/null +++ b/e2e/schreiben.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Schreiben erzeugen — gegen die echte Anwendung. + * + * Der eigentliche Gewinn dieses Issues ist nicht der Brieftext (den gab es + * schon), sondern was danach passiert: Word-Datei, Ablage als + * Ausgangsdokument, Wiedervorlage. Genau dieser Teil lässt sich mit + * Einheitstests nicht prüfen — er berührt Objektspeicher, Datenbank und + * Download. + */ + +const VERTRAG = 'Prüfhausen Police (Test Schreiben)' +const GESELLSCHAFT = 'Prüfhausen Lebensversicherung' + +async function mitDb(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.dokument.deleteMany({ where: { titel: { contains: 'Rückkaufswert' } } }) + await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } }) + await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } }) + await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } }) + await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } }) + }) +} + +async function vertragAnlegen(): Promise { + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + for (const person of betreute) { + await db.vertrag.create({ + data: { + betreuteId: person.id, + bezeichnung: VERTRAG, + gesellschaft: GESELLSCHAFT, + art: 'LEBENSVERSICHERUNG', + beitragCent: 4250n, + rhythmus: 'MONATLICH', + beginnAm: new Date(Date.UTC(2015, 0, 1)), + }, + }) + } + }) +} + +/** + * Findet die Vertrags-ID der AKTIVEN betreuten Person. + * + * Welche Person aktiv ist, haengt an einem Cookie — die Datenbank allein + * weiss es nicht. Der Weg ueber die Vertragsliste ist zugleich der Weg, + * den ein Mensch nimmt. + */ +async function vertragIdAusOberflaeche(page: import('@playwright/test').Page): Promise { + await page.goto('/vertraege') + const link = page.getByRole('link', { name: new RegExp(VERTRAG.replace(/[()]/g, '.')) }).first() + await expect(link).toBeVisible() + const ziel = await link.getAttribute('href') + return (ziel ?? '').split('/').pop() ?? '' +} + +test.describe('Schreiben erzeugen', () => { + test.beforeAll(async () => { + await aufraeumen() + await vertragAnlegen() + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('baut den Entwurf mit Bestellungsabsatz und lässt ihn bearbeiten', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + + const brief = page.locator('textarea#text') + await expect(brief).toBeVisible() + + const text = await brief.inputValue() + // Ohne den Nachweis der Vertretungsmacht antwortet keine Stelle — + // deshalb steht er auch im freien Entwurf drin. + expect(text).toMatch(/Betreuungsgericht|bestellt/i) + // Und die Lücken sind sichtbar, nicht stillschweigend leer. + expect(text).toContain('[') + + await brief.fill(`${text}\n\nZusatz aus dem Test.`) + expect(await brief.inputValue()).toContain('Zusatz aus dem Test.') + }) + + test('erzeugt aus dem Vertrag heraus einen Rückkaufswert-Entwurf', async ({ page }) => { + await anmelden(page) + + const vertragId = await vertragIdAusOberflaeche(page) + expect(vertragId).not.toBe('') + + await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`) + + await expect(page.locator('input#empfaengerName')).toHaveValue(GESELLSCHAFT) + await expect(page.locator('input#betreff')).not.toHaveValue('') + // Die Wiedervorlage ist vorbelegt — 28 Tage. + await expect(page.locator('input#wiedervorlageAm')).not.toHaveValue('') + + const text = await page.locator('textarea#text').inputValue() + expect(text).toContain('Rückkaufswert') + }) + + test('liefert die Word-Datei aus', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + await page.locator('input#empfaengerName').fill('Prüfhausen Amt') + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Als Word-Datei' }).click(), + ]) + + // Sprechender, sortierbarer Name mit Datum vorn. + expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.docx$/) + expect(download.suggestedFilename()).toContain('Pruefhausen') + }) + + test('legt das Schreiben in die Akte und setzt die Wiedervorlage', async ({ page }) => { + await anmelden(page) + + const vertragId = await vertragIdAusOberflaeche(page) + + await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`) + await page.getByRole('button', { name: 'In die Akte legen' }).click() + + await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({ + timeout: 30_000, + }) + await expect(page.getByText(/Wiedervorlage zum/)).toBeVisible() + + // Und es steht wirklich in der Dokumentenliste, als Ausgang. + // + // Bewusst ohne tr/li: Die Liste rendert Tabelle UND Karten, je nach + // Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die + // unsichtbare Fassung sähe wie ein Fehler aus. + await page.goto('/dokumente?richtung=AUSGANG') + + // Die Zusammenfassungszeile gibt es nur einmal — anders als den Titel, + // der in Tabelle UND Kartenliste steht. + await expect(page.getByText('1 Dokument passen zu den Filtern')).toBeVisible() + await expect(page.locator('body')).toContainText('Auskunft über Rückkaufswert') + await expect(page.locator('body')).toContainText('Ausgang') + + // Die Wiedervorlage steht in der Fristenliste. + await page.goto('/fristen') + await expect(page.getByText(/Antwort abwarten/).first()).toBeVisible() + }) + + test('legt aus einer freien Anschrift einen Kontakt an', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + + await page.locator('input#empfaengerName').fill('Prüfhausen Sozialamt') + await page.locator('textarea#empfaengerAnschrift').fill('Musterweg 3\n96450 Coburg') + + await page.getByRole('button', { name: 'Als Kontakt merken' }).click() + await expect(page.getByText(/steht jetzt in den Kontakten/)).toBeVisible() + + // Beim nächsten Mal steht er zur Auswahl. + await page.goto('/schreiben/neu?art=FREI') + await expect(page.locator('select#kontaktAuswahl')).toContainText('Prüfhausen Sozialamt') + }) +}) diff --git a/package.json b/package.json index aee79c0..6397e66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/src/app/(app)/schreiben/docx/route.ts b/src/app/(app)/schreiben/docx/route.ts new file mode 100644 index 0000000..5601c4d --- /dev/null +++ b/src/app/(app)/schreiben/docx/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung' +import { briefDocxErzeugen } from '@/lib/schreiben/docx' +import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten' +import { heuteAlsTag } from '@/lib/fristen/datum' +import { tagAusText } from '@/lib/finanzen/datum' + +/** + * Das Schreiben als Word-Datei herunterladen. + * + * Warum eine Route und keine Server Action? Eine Action kann keinen + * Download ausloesen - sie muesste die Datei kodiert zurueckgeben und der + * Browser sie nachbauen. Eine Route liefert sie unmittelbar aus, mit + * richtigem Namen und Typ. Denselben Weg geht schon der Vollexport. + * + * POST und nicht GET, weil der bearbeitete Brieftext mitkommt und in eine + * Adresszeile weder hineinpasst noch hineingehoert: Er stuende sonst im + * Verlauf des Browsers und in jedem Zwischenspeicher. + */ + +export const dynamic = 'force-dynamic' + +const MIME_DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + +export async function POST(anfrage: Request): Promise { + let formular: FormData + try { + formular = await anfrage.formData() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const betreuteId = String(formular.get('betreuteId') ?? '') + const rohArt = String(formular.get('art') ?? '') + const text = String(formular.get('text') ?? '') + const empfaenger = String(formular.get('empfaengerName') ?? '').trim() + const versandRoh = String(formular.get('versandAm') ?? '').trim() + + if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) { + return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 }) + } + + try { + // Zugriff wird hier geprueft und nicht nur auf der Seite davor: Eine + // Route laesst sich unmittelbar aufrufen. + await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen') + } catch (ausnahme) { + if (ausnahme instanceof ZugriffVerweigert) { + return NextResponse.json({ fehler: ausnahme.message }, { status: 403 }) + } + throw ausnahme + } + + const datum = tagAusText(versandRoh) ?? heuteAlsTag() + const datei = await briefDocxErzeugen(text) + const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, datum, 'docx') + + return new NextResponse(new Uint8Array(datei), { + headers: { + 'Content-Type': MIME_DOCX, + 'Content-Disposition': `attachment; filename="${dateiname}"`, + 'Content-Length': String(datei.length), + // Ein Schreiben aus einer Betreuungsakte gehoert in keinen + // Zwischenspeicher. + 'Cache-Control': 'no-store, must-revalidate', + }, + }) +} diff --git a/src/app/(app)/schreiben/neu/page.tsx b/src/app/(app)/schreiben/neu/page.tsx new file mode 100644 index 0000000..dbd2346 --- /dev/null +++ b/src/app/(app)/schreiben/neu/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle, Scale } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { SchreibenEditor } from '@/components/schreiben/schreiben-editor' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { entwurfBauen } from '@/lib/schreiben/entwurf' +import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten' + +export const metadata: Metadata = { title: 'Schreiben' } +export const dynamic = 'force-dynamic' + +/** + * Ein Schreiben erzeugen, bearbeiten, ausgeben und ablegen. + * + * Eine Seite für alle Arten und alle drei Empfängerwege — statt die + * Bearbeitung dreimal an Vertrag, Vorgang und Freitext nachzubauen. Die + * bestehenden Seiten verlinken hierher. + * + * Der Fall ohne Bezug ist ausdrücklich vorgesehen: Am Anfang einer + * Betreuung schreibt man an Stellen, zu denen es noch keinen Datensatz + * gibt — man fragt ja gerade an, um zu erfahren, ob dort etwas besteht. + */ +export default async function SchreibenSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams + const benutzer = await erfordereBenutzer('/schreiben/neu') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen') + if (!darfSchreiben('DOKUMENTE')) redirect('/dokumente') + + const text = (name: string): string => { + const wert = parameter[name] + return typeof wert === 'string' ? wert.trim() : '' + } + + const rohArt = text('art') + const art = istSchreibenArt(rohArt) ? rohArt : 'FREI' + const eigenschaften = SCHREIBEN_EIGENSCHAFTEN[art] + + let entwurf + try { + entwurf = await entwurfBauen({ + betreuteId: aktive.id, + benutzerName: benutzer.name, + art, + vertragId: text('vertragId') || null, + vorgangId: text('vorgangId') || null, + }) + } catch (ausnahme) { + // Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine + // Angabe, die nicht stimmt. Die Seite sagt es und bietet den freien + // Weg an, statt den Benutzer vor eine Fehlerseite zu setzen. + const meldung = + ausnahme instanceof Error ? ausnahme.message : 'Der Entwurf ließ sich nicht bauen.' + return ( +
+ + + +
+ ) + } + + const kontakte = await db.kontakt.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, name: true, organisation: true, strasse: true, plz: true, ort: true }, + orderBy: [{ organisation: 'asc' }, { name: 'asc' }], + take: 200, + }) + + return ( +
+ + + + + + +
+ ) +} diff --git a/src/app/(app)/vorgaenge/[id]/page.tsx b/src/app/(app)/vorgaenge/[id]/page.tsx index 915eb55..df9c211 100644 --- a/src/app/(app)/vorgaenge/[id]/page.tsx +++ b/src/app/(app)/vorgaenge/[id]/page.tsx @@ -1,379 +1,387 @@ -import { notFound } from 'next/navigation' -import Link from 'next/link' -import { ArrowLeft, Clock } from 'lucide-react' -import type { VorgangStatus } from '@prisma/client' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { SeitenKopf } from '@/components/seiten-kopf' -import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' -import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' -import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' -import { - GenehmigungsKette, - type GenehmigungsAnzeige, -} from '@/components/vorgaenge/genehmigungs-kette' -import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' -import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' -import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' -import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' -import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' -import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' -import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' -import { - VORGANG_STATUS_BESCHRIFTUNG, - zeitstrahlBauen, - type ZeitstrahlStatuswechsel, -} from '@/lib/vorgaenge/zeitstrahl' -import { formatDatum } from '@/lib/utils' - -/** - * Die Fallakte. - * - * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; - * darunter die Aufgabenliste, das Genehmigungsverfahren und der - * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. - */ -export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) - const aktive = await aktiveBetreute(benutzer) - if (!aktive) notFound() - - const vorgang = await db.vorgang.findFirst({ - where: { id, betreuteId: aktive.id, ...nichtGeloescht }, - include: { - aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, - genehmigungen: { - where: nichtGeloescht, - include: { beschlussDokument: { select: { id: true, titel: true } } }, - orderBy: { erstelltAm: 'asc' }, - }, - fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, - buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, - kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, - verknuepfungen: { include: { dokument: true } }, - }, - }) - if (!vorgang) notFound() - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') - const schreiben = darfSchreiben('VORGAENGE') - - const offen = oeffnen('Vorgang', vorgang) - - // --- Statuswechsel aus dem Audit-Log -------------------------------------- - // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die - // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. - const protokolle = await db.auditLog.findMany({ - where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, - orderBy: { zeitpunkt: 'asc' }, - take: 200, - }) - - const statuswechsel: ZeitstrahlStatuswechsel[] = [] - for (const eintrag of protokolle) { - const roh = entschluesselnWennMoeglich( - eintrag.aenderungVerschluesselt, - 'aenderungVerschluesselt', - ) - if (!roh) continue - try { - const gelesen = JSON.parse(roh) as Record - const status = gelesen.status - if (!status || typeof status.neu !== 'string') continue - statuswechsel.push({ - id: eintrag.id, - zeitpunkt: eintrag.zeitpunkt, - von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, - nach: status.neu as VorgangStatus, - wer: eintrag.userName, - }) - } catch { - // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. - continue - } - } - - // --- Zeitstrahl ----------------------------------------------------------- - const zeitstrahl = zeitstrahlBauen({ - vorgang: { - titel: vorgang.titel, - erstelltAm: vorgang.erstelltAm, - erledigtAm: vorgang.erledigtAm, - }, - dokumente: vorgang.verknuepfungen - .map((v) => v.dokument) - .filter((d) => d.geloeschtAm === null) - .map((d) => ({ - id: d.id, - titel: d.titel, - eingangAm: d.eingangAm, - versandAm: d.versandAm, - erstelltAm: d.erstelltAm, - absender: d.absender, - empfaenger: d.empfaenger, - })), - kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { - const klar = oeffnen('Kontaktprotokoll', p) - return { - id: p.id, - datum: p.datum, - art: p.art, - gespraechspartner: p.gespraechspartner, - mitBetreuter: p.mitBetreuter, - inhalt: klar.inhalt ?? null, - } - }), - fristen: vorgang.fristen.map((f) => ({ - id: f.id, - titel: f.titel, - datum: f.datum, - erledigtAm: f.erledigtAm, - ausschlussfrist: f.ausschlussfrist, - rechtsgrundlage: f.rechtsgrundlage, - })), - buchungen: vorgang.buchungen.map((b) => { - const klar = oeffnen('Buchung', b) - return { - id: b.id, - datum: b.datum, - betragCent: centZuZahl(b.betragCent) ?? 0, - richtung: b.richtung, - gegenstelle: b.gegenstelle, - verwendungszweck: klar.verwendungszweck ?? null, - } - }), - genehmigungen: vorgang.genehmigungen.map((g) => ({ - id: g.id, - titel: g.titel, - rechtsgrundlage: g.rechtsgrundlage, - status: g.status, - mitteilungAm: g.mitteilungAm, - antragAm: g.antragAm, - anhoerungAm: g.anhoerungAm, - beschlussAm: g.beschlussAm, - rechtskraftAm: g.rechtskraftAm, - vollzugAm: g.vollzugAm, - })), - statuswechsel, - }) - - // --- Sachstandsanfrage ---------------------------------------------------- - // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der - // Knopf nur Zierde. - const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' - const anfrage = wartetAufDritte - ? sachstandsanfrageErzeugen({ - vorgangTitel: vorgang.titel, - gegenstelle: vorgang.gegenstelle, - aktenzeichen: vorgang.aktenzeichen, - letzterKontaktAm: vorgang.letzterKontaktAm, - betreute: { - vorname: aktive.vorname, - nachname: aktive.nachname, - geburtsdatum: aktive.geburtsdatum, - }, - betreuer: { - name: benutzer.name, - aktenzeichenGericht: aktive.aktenzeichenGericht, - betreuungsgericht: aktive.betreuungsgericht, - }, - }) - : null - - // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. - const dokumente = vorgang.genehmigungen.length - ? await db.dokument.findMany({ - where: { betreuteId: aktive.id, ...nichtGeloescht }, - select: { id: true, titel: true }, - orderBy: { erstelltAm: 'desc' }, - take: 100, - }) - : [] - - const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ - id: a.id, - titel: a.titel, - beschreibung: a.beschreibung, - erledigt: a.erledigt, - faelligAm: a.faelligAm, - })) - - const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ - id: g.id, - titel: g.titel, - rechtsgrundlage: g.rechtsgrundlage, - status: g.status, - mitteilungAm: g.mitteilungAm, - antragAm: g.antragAm, - anhoerungAm: g.anhoerungAm, - beschlussAm: g.beschlussAm, - rechtskraftAm: g.rechtskraftAm, - vollzugAm: g.vollzugAm, - beschlussDokumentId: g.beschlussDokumentId, - beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, - dokumente, - })) - - return ( -
- - - {anfrage && ( - - )} - {schreiben && } - - } - /> - - {/* --- Kopf der Akte ---------------------------------------------- */} - - -
-

Status

-

{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}

- {schreiben && ( - - )} -
- -
-

Gegenstelle

-

{vorgang.gegenstelle ?? '–'}

- {vorgang.letzterKontaktAm && ( -

-

- )} -
- -
-

Aktenzeichen

-

{vorgang.aktenzeichen ?? '–'}

-
- -
-

Nächster Schritt

-

- {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} -

-
- -
-

Einordnung

-
- {KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]} - - {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} - - -
-
-
-
- - {/* --- Aufgaben ----------------------------------------------------- */} -
-

- Aufgaben -

- -
- - {/* --- Genehmigungen ------------------------------------------------ */} - {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( -
-

- Genehmigung des Betreuungsgerichts -

- - {genehmigungen.map((genehmigung) => ( - - ))} - - {schreiben && genehmigungen.length === 0 && ( - - )} -
- )} - - {/* --- Zeitstrahl --------------------------------------------------- */} -
-

- Zeitstrahl -

-

- Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste - zuerst. -

- -
- - {/* --- Bearbeiten --------------------------------------------------- */} - {schreiben && ( -
- - - Angaben bearbeiten - - - - - -
- )} -
- ) -} +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, FileDown, Clock } from 'lucide-react' +import type { VorgangStatus } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' +import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' +import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' +import { + GenehmigungsKette, + type GenehmigungsAnzeige, +} from '@/components/vorgaenge/genehmigungs-kette' +import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' +import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' +import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' +import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' +import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' +import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' +import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' +import { + VORGANG_STATUS_BESCHRIFTUNG, + zeitstrahlBauen, + type ZeitstrahlStatuswechsel, +} from '@/lib/vorgaenge/zeitstrahl' +import { formatDatum } from '@/lib/utils' + +/** + * Die Fallakte. + * + * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; + * darunter die Aufgabenliste, das Genehmigungsverfahren und der + * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. + */ +export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) notFound() + + const vorgang = await db.vorgang.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, + genehmigungen: { + where: nichtGeloescht, + include: { beschlussDokument: { select: { id: true, titel: true } } }, + orderBy: { erstelltAm: 'asc' }, + }, + fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, + buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + verknuepfungen: { include: { dokument: true } }, + }, + }) + if (!vorgang) notFound() + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') + const schreiben = darfSchreiben('VORGAENGE') + + const offen = oeffnen('Vorgang', vorgang) + + // --- Statuswechsel aus dem Audit-Log -------------------------------------- + // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die + // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. + const protokolle = await db.auditLog.findMany({ + where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, + orderBy: { zeitpunkt: 'asc' }, + take: 200, + }) + + const statuswechsel: ZeitstrahlStatuswechsel[] = [] + for (const eintrag of protokolle) { + const roh = entschluesselnWennMoeglich( + eintrag.aenderungVerschluesselt, + 'aenderungVerschluesselt', + ) + if (!roh) continue + try { + const gelesen = JSON.parse(roh) as Record + const status = gelesen.status + if (!status || typeof status.neu !== 'string') continue + statuswechsel.push({ + id: eintrag.id, + zeitpunkt: eintrag.zeitpunkt, + von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, + nach: status.neu as VorgangStatus, + wer: eintrag.userName, + }) + } catch { + // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. + continue + } + } + + // --- Zeitstrahl ----------------------------------------------------------- + const zeitstrahl = zeitstrahlBauen({ + vorgang: { + titel: vorgang.titel, + erstelltAm: vorgang.erstelltAm, + erledigtAm: vorgang.erledigtAm, + }, + dokumente: vorgang.verknuepfungen + .map((v) => v.dokument) + .filter((d) => d.geloeschtAm === null) + .map((d) => ({ + id: d.id, + titel: d.titel, + eingangAm: d.eingangAm, + versandAm: d.versandAm, + erstelltAm: d.erstelltAm, + absender: d.absender, + empfaenger: d.empfaenger, + })), + kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { + const klar = oeffnen('Kontaktprotokoll', p) + return { + id: p.id, + datum: p.datum, + art: p.art, + gespraechspartner: p.gespraechspartner, + mitBetreuter: p.mitBetreuter, + inhalt: klar.inhalt ?? null, + } + }), + fristen: vorgang.fristen.map((f) => ({ + id: f.id, + titel: f.titel, + datum: f.datum, + erledigtAm: f.erledigtAm, + ausschlussfrist: f.ausschlussfrist, + rechtsgrundlage: f.rechtsgrundlage, + })), + buchungen: vorgang.buchungen.map((b) => { + const klar = oeffnen('Buchung', b) + return { + id: b.id, + datum: b.datum, + betragCent: centZuZahl(b.betragCent) ?? 0, + richtung: b.richtung, + gegenstelle: b.gegenstelle, + verwendungszweck: klar.verwendungszweck ?? null, + } + }), + genehmigungen: vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + })), + statuswechsel, + }) + + // --- Sachstandsanfrage ---------------------------------------------------- + // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der + // Knopf nur Zierde. + const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' + const anfrage = wartetAufDritte + ? sachstandsanfrageErzeugen({ + vorgangTitel: vorgang.titel, + gegenstelle: vorgang.gegenstelle, + aktenzeichen: vorgang.aktenzeichen, + letzterKontaktAm: vorgang.letzterKontaktAm, + betreute: { + vorname: aktive.vorname, + nachname: aktive.nachname, + geburtsdatum: aktive.geburtsdatum, + }, + betreuer: { + name: benutzer.name, + aktenzeichenGericht: aktive.aktenzeichenGericht, + betreuungsgericht: aktive.betreuungsgericht, + }, + }) + : null + + // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. + const dokumente = vorgang.genehmigungen.length + ? await db.dokument.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, titel: true }, + orderBy: { erstelltAm: 'desc' }, + take: 100, + }) + : [] + + const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ + id: a.id, + titel: a.titel, + beschreibung: a.beschreibung, + erledigt: a.erledigt, + faelligAm: a.faelligAm, + })) + + const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + beschlussDokumentId: g.beschlussDokumentId, + beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, + dokumente, + })) + + return ( +
+ + + {anfrage && ( + + )} + {schreiben && ( + + )} + {schreiben && } + + } + /> + + {/* --- Kopf der Akte ---------------------------------------------- */} + + +
+

Status

+

{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}

+ {schreiben && ( + + )} +
+ +
+

Gegenstelle

+

{vorgang.gegenstelle ?? '–'}

+ {vorgang.letzterKontaktAm && ( +

+

+ )} +
+ +
+

Aktenzeichen

+

{vorgang.aktenzeichen ?? '–'}

+
+ +
+

Nächster Schritt

+

+ {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} +

+
+ +
+

Einordnung

+
+ {KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]} + + {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} + + +
+
+
+
+ + {/* --- Aufgaben ----------------------------------------------------- */} +
+

+ Aufgaben +

+ +
+ + {/* --- Genehmigungen ------------------------------------------------ */} + {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( +
+

+ Genehmigung des Betreuungsgerichts +

+ + {genehmigungen.map((genehmigung) => ( + + ))} + + {schreiben && genehmigungen.length === 0 && ( + + )} +
+ )} + + {/* --- Zeitstrahl --------------------------------------------------- */} +
+

+ Zeitstrahl +

+

+ Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste + zuerst. +

+ +
+ + {/* --- Bearbeiten --------------------------------------------------- */} + {schreiben && ( +
+ + + Angaben bearbeiten + + + + + +
+ )} +
+ ) +} diff --git a/src/components/schreiben/schreiben-editor.tsx b/src/components/schreiben/schreiben-editor.tsx new file mode 100644 index 0000000..d953282 --- /dev/null +++ b/src/components/schreiben/schreiben-editor.tsx @@ -0,0 +1,356 @@ +'use client' + +import * as React from 'react' +import { useActionState } from 'react' +import Link from 'next/link' +import { + AlertCircle, + CheckCircle2, + Copy, + FileDown, + FolderInput, + Info, + UserPlus, +} from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Select } from '@/components/ui/select' +import { Feld } from '@/components/feld' +import { Absendeknopf } from '@/components/finanzen/eingaben' +import { + kontaktAusAnschrift, + schreibenAblegen, + type SchreibenZustand, +} from '@/server/actions/schreiben' +import type { SchreibenArt } from '@/lib/schreiben/arten' + +export type KontaktAuswahl = { + id: string + name: string + organisation: string | null + strasse: string | null + plz: string | null + ort: string | null +} + +/** + * Schreiben bearbeiten, herunterladen, ablegen. + * + * Der Text steht in einem gewöhnlichen Textfeld und ist vollständig + * bearbeitbar. Das ist Absicht: Jeder Fall hat eine Besonderheit, die in + * den Brief gehört. Ein Formulartext, den man nicht mehr ändern kann, + * wird früher oder später falsch abgeschickt. + */ +export function SchreibenEditor({ + betreuteId, + art, + entwurf, + kontakte, + vertragId, + vorgangId, + fristBegruendung, + warnungen, +}: { + betreuteId: string + art: SchreibenArt + entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string } + kontakte: KontaktAuswahl[] + vertragId: string | null + vorgangId: string | null + fristBegruendung: string | null + warnungen: string[] +}) { + const [text, setText] = React.useState(entwurf.text) + const [betreff, setBetreff] = React.useState(entwurf.betreff) + const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) + const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') + const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10)) + const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm) + const [kopiert, setKopiert] = React.useState(false) + + const [ablageZustand, ablegen] = useActionState( + schreibenAblegen, + {}, + ) + const [kontaktZustand, kontaktAnlegen] = useActionState( + kontaktAusAnschrift, + {}, + ) + + /** Gemeinsame versteckte Felder für beide Formulare und den Download. */ + function VersteckteFelder() { + return ( + <> + + + + + + + + + + + + ) + } + + function kontaktUebernehmen(id: string) { + const kontakt = kontakte.find((k) => k.id === id) + if (!kontakt) return + setEmpfaengerName(kontakt.organisation ?? kontakt.name) + setEmpfaengerAnschrift( + [kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')] + .filter(Boolean) + .join('\n'), + ) + } + + async function textKopieren() { + try { + await navigator.clipboard.writeText(text) + setKopiert(true) + window.setTimeout(() => setKopiert(false), 2500) + } catch { + /* Zwischenablage gesperrt - dann markiert man eben von Hand. */ + } + } + + const abgelegt = Boolean(ablageZustand.erfolg) + + return ( +
+ {warnungen.length > 0 && ( + + + )} + + {/* --- Empfänger ---------------------------------------------------- */} + + + An wen geht das Schreiben? + + Der Empfänger steht im Brieftext darunter — was du hier einträgst, landet am Dokument + in der Akte und im Dateinamen. + + + + {kontakte.length > 0 && ( + + + + )} + + + setEmpfaengerName(ereignis.target.value)} + maxLength={200} + placeholder="z. B. Allianz Lebensversicherungs-AG" + /> + + + +