diff --git a/CHANGELOG.md b/CHANGELOG.md index 219e4fd..1ef1b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,63 @@ Wiederherstellung*. _Noch nichts._ +## [1.9.0] – 2026-08-23 + +### Hinzugefügt + +- **Ansprechpartner am Vorgang** + ([#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9)). + Ein namentlich adressiertes Schreiben landet bei der Person, die den + Vorgang kennt, statt in der Poststelle — beim Bezirk ist das der + Unterschied zwischen zwei Wochen und zwei Monaten. Am Vorgang lässt + sich jetzt ein Kontakt aus dem Verzeichnis als Ansprechpartner + benennen. + +- **Die Sachstandsanfrage redet ihn an.** Aus „Sehr geehrte Damen und + Herren" wird „Sehr geehrte Frau Sachs" — und der Name steht über der + Behörde, damit die Poststelle den Brief zur Person sortiert. Auch das + Schreiben aus dem Editor übernimmt Anrede, Empfänger und Anschrift, + sodass nichts abgetippt werden muss. + +- **Anrede am Kontakt.** Damit die Briefanrede stimmt, trägt ein Kontakt + wahlweise „Frau" oder „Herr". **Geraten wird nie:** Ohne Angabe wird + mit vollem Namen angeredet („Sehr geehrte(r) Kim Berger,"). Eine + falsch geratene Anrede fällt dem Empfänger sofort auf und kostet genau + die Aufmerksamkeit, die man gewinnen wollte. + +- **Vor dem Anruf weiß man, wen man fragt.** Auf der Vorgangsseite steht + der Ansprechpartner mit Organisation, Durchwahl und E-Mail, verlinkt + auf den Kontakt. + +### Zwei Zusagen + +**Ohne Ansprechpartner ändert sich nichts.** Ist keiner benannt, bleibt +es bei „Sehr geehrte Damen und Herren" — und die Vorgangsseite sagt das +auch, statt ein leeres Feld zu zeigen. Ein halb gefüllter Name führt nie +zu einer verstümmelten Anrede wie „Sehr geehrte Frau ,". + +**Der Wechsel bleibt nachvollziehbar.** Wer den Ansprechpartner +austauscht, findet den Vorgänger im Protokoll — mit **Namen**, nicht mit +der internen Kennung. Zwei Kennungen nebeneinander wären protokolliert, +aber nicht nachvollziehbar. + +### Datenbank + +Eine Migration: `ansprechpartnerId` am Vorgang und `anrede` am Kontakt. +Wird ein Kontakt gelöscht, bleibt der Vorgang bestehen und verliert nur +den Verweis — ein Vorgang darf nicht daran hängen, dass es den Kontakt +noch gibt. + +### Geprüft + +- Neun Einheitstests für die Anrede, darunter der Nachname aus „Dr. Anna + Maria Schmitt", der Doppelname und der Fall, der nie passieren darf: + leerer Name mit gesetzter Anrede. +- Fünf End-to-End-Tests: die allgemeine Anrede ohne Ansprechpartner, das + Benennen samt Anzeige mit Durchwahl, die namentliche Anrede in der + Sachstandsanfrage, die vorbelegte Anschrift im Schreiben und der + Wechsel im Protokoll. + ## [1.8.0] – 2026-08-23 ### Hinzugefügt @@ -979,7 +1036,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.8.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...HEAD +[1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0 [1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0 [1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0 [1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 8d22edb..b0a829c 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.8.0 +git checkout v1.9.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.8.0 +git describe --tags # v1.9.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.8.0","commit":"..."} +# {"status":"bereit","version":"1.9.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.8.0",...} +# {"status":"bereit","version":"1.9.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.8.0 · `. +- Unten links in der Seitenleiste steht `Version 1.9.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.8.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.9.0` | auf eine bestimmte Fassung | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --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.8.0 +git checkout v1.9.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 9cf78eb..2bcc169 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.8.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.9.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/ansprechpartner.spec.ts b/e2e/ansprechpartner.spec.ts new file mode 100644 index 0000000..c812ac4 --- /dev/null +++ b/e2e/ansprechpartner.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Ansprechpartner am Vorgang — gegen die echte Anwendung. + * + * Geprüft wird die Zusage, um die es geht: Ein Schreiben zum Vorgang + * redet die Sachbearbeiterin namentlich an und trägt ihren Namen über + * der Behörde — ohne benannten Ansprechpartner bleibt alles beim Alten. + */ + +const KENNUNG = 'Prüfhausen Partner' + +async function mitDb(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.vorgang.deleteMany({ where: { titel: { contains: KENNUNG } } }) + await db.kontakt.deleteMany({ where: { name: { contains: KENNUNG } } }) + }) +} + +test.describe('Ansprechpartner am Vorgang', () => { + test.beforeAll(async () => { + await aufraeumen() + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + for (const person of betreute) { + await db.kontakt.create({ + data: { + betreuteId: person.id, + name: `${KENNUNG} Petra Sachs`, + organisation: `${KENNUNG} Bezirk`, + rolle: 'SACHBEARBEITER_BEZIRK', + anrede: 'FRAU', + telefon: '0921 604-1234', + strasse: 'Cottenbacher Str. 23', + plz: '95445', + ort: 'Bayreuth', + }, + }) + await db.vorgang.create({ + data: { + betreuteId: person.id, + titel: `${KENNUNG} Antrag auf Hilfe zur Pflege`, + kategorie: 'SOZIALHILFE', + status: 'WARTET_AUF_DRITTE', + gegenstelle: `${KENNUNG} Bezirk`, + aktenzeichen: 'HzP 2026/4711', + }, + }) + } + }) + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('bleibt ohne benannten Ansprechpartner bei der allgemeinen Anrede', async ({ page }) => { + await anmelden(page) + await page.goto('/vorgaenge') + await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click() + await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/) + + await expect(page.getByText('Keiner benannt', { exact: false }).first()).toBeVisible() + + await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click() + const text = await page.locator('textarea#sachstand-text').inputValue() + expect(text).toContain('Sehr geehrte Damen und Herren,') + }) + + test('lässt einen Kontakt als Ansprechpartner wählen und zeigt ihn an', async ({ page }) => { + await anmelden(page) + await page.goto('/vorgaenge') + await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click() + await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/) + + await page + .locator('select[name="ansprechpartnerId"]') + .selectOption({ label: `${KENNUNG} Petra Sachs · ${KENNUNG} Bezirk` }) + await page.getByRole('button', { name: 'Speichern' }).click() + await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible() + + await page.reload() + const inhalt = await page.locator('body').innerText() + // Vor dem Anruf weiß man, wen man fragt — samt Durchwahl. + expect(inhalt).toContain(`${KENNUNG} Petra Sachs`) + expect(inhalt).toContain('0921 604-1234') + }) + + test('redet die Sachstandsanfrage namentlich an', async ({ page }) => { + await anmelden(page) + await page.goto('/vorgaenge') + await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click() + await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/) + + await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click() + const text = await page.locator('textarea#sachstand-text').inputValue() + expect(text).toContain('Sehr geehrte Frau Sachs,') + expect(text).not.toContain('Sehr geehrte Damen und Herren,') + // Der Name steht über der Behörde. + const zeilen = text.split('\n') + expect(zeilen[0]).toContain('Petra Sachs') + }) + + test('übernimmt Name und Anschrift in das Schreiben zum Vorgang', async ({ page }) => { + const vorgangId = await mitDb(async (db) => { + const vorgang = await db.vorgang.findFirst({ + where: { titel: { contains: KENNUNG }, ansprechpartnerId: { not: null } }, + select: { id: true }, + }) + return vorgang?.id ?? '' + }) + expect(vorgangId).not.toBe('') + + await anmelden(page) + await page.goto(`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgangId}`) + + await expect(page.locator('textarea#text')).toBeVisible() + const text = await page.locator('textarea#text').inputValue() + expect(text).toContain('Sehr geehrte Frau Sachs,') + + // Empfänger und Anschrift sind vorbelegt, nichts abzutippen. + await expect(page.locator('input[name="empfaengerName"]').first()).toHaveValue( + `${KENNUNG} Petra Sachs`, + ) + expect(await page.locator('textarea#empfaengerAnschrift').first().inputValue()).toContain( + '95445 Bayreuth', + ) + }) + + test('hält den Wechsel des Ansprechpartners mit Namen im Protokoll fest', async ({ page }) => { + await anmelden(page) + await page.goto('/vorgaenge') + await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click() + await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/) + + // Zurück auf „keiner benannt" — der Wechsel muss nachvollziehbar sein. + await page.locator('select[name="ansprechpartnerId"]').selectOption('') + await page.getByRole('button', { name: 'Speichern' }).click() + await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible() + + // Im Protokoll steht der Name, nicht die Id: Zwei cuids nebeneinander + // sind protokolliert, aber nicht nachvollziehbar. + await page.goto('/protokoll') + // textContent statt innerText: Die Felderliste steckt in einem + // zugeklappten
. + const protokoll = (await page.locator('body').textContent()) ?? '' + expect(protokoll).toContain('ansprechpartner') + expect(protokoll).toContain(`${KENNUNG} Petra Sachs`) + }) +}) diff --git a/package.json b/package.json index f155903..66d4f64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/migrations/20260823153000_ansprechpartner/migration.sql b/prisma/migrations/20260823153000_ansprechpartner/migration.sql new file mode 100644 index 0000000..030747d --- /dev/null +++ b/prisma/migrations/20260823153000_ansprechpartner/migration.sql @@ -0,0 +1,17 @@ +-- Ansprechpartner am Vorgang, Anrede am Kontakt. +-- +-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die +-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt. +-- Wer die naechste Migration erzeugt: nachsehen. + +-- CreateEnum +CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR'); + +-- AlterTable +ALTER TABLE "kontakt" ADD COLUMN "anrede" "KontaktAnrede" NOT NULL DEFAULT 'OHNE'; + +-- AlterTable +ALTER TABLE "vorgang" ADD COLUMN "ansprechpartnerId" TEXT; + +-- AddForeignKey +ALTER TABLE "vorgang" ADD CONSTRAINT "vorgang_ansprechpartnerId_fkey" FOREIGN KEY ("ansprechpartnerId") REFERENCES "kontakt"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2646987..79cb161 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -373,6 +373,14 @@ model Vorgang { gegenstelle String? aktenzeichen String? + /// Die Person bei der Gegenstelle, die den Vorgang bearbeitet. Ein + /// namentlich adressiertes Schreiben landet bei ihr statt in der + /// Poststelle - beim Bezirk ist das der Unterschied zwischen zwei + /// Wochen und zwei Monaten. SetNull: Ein Vorgang darf nicht daran + /// haengen, dass es den Kontakt noch gibt. + ansprechpartnerId String? + ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull) + verantwortlich String? angelegtVonId String? angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) @@ -1233,6 +1241,13 @@ model Vertrag { // Kontakte und Kontaktprotokoll // ============================================================================= +/// Anrede fuer die Briefanrede eines Kontakts. +enum KontaktAnrede { + OHNE + FRAU + HERR +} + enum KontaktRolle { RECHTSPFLEGER RICHTER @@ -1267,7 +1282,10 @@ model Kontakt { name String organisation String? - rolle KontaktRolle @default(SONSTIGES) + rolle KontaktRolle @default(SONSTIGES) + /// Fuer die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet - + /// nie geraten, das faellt dem Empfaenger sofort auf. + anrede KontaktAnrede @default(OHNE) telefon String? mobil String? @@ -1285,9 +1303,10 @@ model Kontakt { aktualisiertAm DateTime @updatedAt geloeschtAm DateTime? - protokolle Kontaktprotokoll[] - vorgaenge Vorgang[] @relation("VorgangKontakte") - verknuepfungen DokumentVerknuepfung[] + protokolle Kontaktprotokoll[] + vorgaenge Vorgang[] @relation("VorgangKontakte") + betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner") + verknuepfungen DokumentVerknuepfung[] @@index([betreuteId, rolle]) @@map("kontakt") diff --git a/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx b/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx index 2612ab3..b924a39 100644 --- a/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx +++ b/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx @@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({ name: kontakt.name, organisation: kontakt.organisation, rolle: kontakt.rolle, + anrede: kontakt.anrede, telefon: kontakt.telefon, mobil: kontakt.mobil, fax: kontakt.fax, diff --git a/src/app/(app)/schreiben/neu/page.tsx b/src/app/(app)/schreiben/neu/page.tsx index ce27f10..d42345f 100644 --- a/src/app/(app)/schreiben/neu/page.tsx +++ b/src/app/(app)/schreiben/neu/page.tsx @@ -126,6 +126,7 @@ export default async function SchreibenSeite({ betreff: entwurf.betreff, text: entwurf.text, empfaengerName: entwurf.empfaengerName, + empfaengerAnschrift: entwurf.empfaengerAnschrift, wiedervorlageAm: entwurf.wiedervorlageAm, }} kontakte={kontakte} diff --git a/src/app/(app)/vorgaenge/[id]/page.tsx b/src/app/(app)/vorgaenge/[id]/page.tsx index df9c211..1a04df3 100644 --- a/src/app/(app)/vorgaenge/[id]/page.tsx +++ b/src/app/(app)/vorgaenge/[id]/page.tsx @@ -56,6 +56,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, verknuepfungen: { include: { dokument: true } }, + ansprechpartner: true, }, }) if (!vorgang) notFound() @@ -65,6 +66,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s const offen = oeffnen('Vorgang', vorgang) + // Kontakte dieser Akte fuer die Wahl des Ansprechpartners. + const kontakte = await db.kontakt.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, name: true, organisation: true }, + orderBy: [{ organisation: 'asc' }, { name: 'asc' }], + take: 300, + }) + // --- Statuswechsel aus dem Audit-Log -------------------------------------- // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. @@ -182,6 +191,12 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s aktenzeichenGericht: aktive.aktenzeichenGericht, betreuungsgericht: aktive.betreuungsgericht, }, + ansprechpartner: vorgang.ansprechpartner + ? { + name: vorgang.ansprechpartner.name, + anrede: vorgang.ansprechpartner.anrede, + } + : null, }) : null @@ -285,6 +300,40 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s

{vorgang.aktenzeichen ?? '–'}

+ {/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse + stehen dabei - sonst schlaegt man sie doch wieder nach. */} +
+

Ansprechpartner

+ {vorgang.ansprechpartner ? ( + <> +

+ + {vorgang.ansprechpartner.name} + +

+ {vorgang.ansprechpartner.organisation && ( +

+ {vorgang.ansprechpartner.organisation} +

+ )} + {(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && ( +

+ {[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email] + .filter(Boolean) + .join(' · ')} +

+ )} + + ) : ( +

+ Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“. +

+ )} +
+

Nächster Schritt

@@ -363,6 +412,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s betreuteId={aktive.id} vorgangId={vorgang.id} abbrechenHref={`/vorgaenge/${vorgang.id}`} + kontakte={kontakte} werte={{ titel: vorgang.titel, kategorie: vorgang.kategorie, @@ -371,6 +421,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s beschreibung: offen.beschreibung ?? null, gegenstelle: vorgang.gegenstelle, aktenzeichen: vorgang.aktenzeichen, + ansprechpartnerId: vorgang.ansprechpartnerId, verantwortlich: vorgang.verantwortlich, naechsterSchritt: vorgang.naechsterSchritt, faelligAm: vorgang.faelligAm, diff --git a/src/app/(app)/vorgaenge/neu/page.tsx b/src/app/(app)/vorgaenge/neu/page.tsx index fd6274e..07321df 100644 --- a/src/app/(app)/vorgaenge/neu/page.tsx +++ b/src/app/(app)/vorgaenge/neu/page.tsx @@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette' import { vorlageFinden } from '@/lib/vorgaenge/vorlagen' +import { db, nichtGeloescht } from '@/lib/db' import { formatDatum } from '@/lib/utils' import { addDays } from 'date-fns' @@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({ await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben') + // Kontakte dieser Akte fuer die Wahl des Ansprechpartners. + const kontakte = await db.kontakt.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, name: true, organisation: true }, + orderBy: [{ organisation: 'asc' }, { name: 'asc' }], + take: 300, + }) + const auswahl = await searchParams const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined const leer = auswahl.leer === '1' @@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({ betreuteId={aktive.id} vorlageSchluessel={vorlage?.schluessel ?? null} abbrechenHref="/vorgaenge" + kontakte={kontakte} werte={{ titel: vorlage?.titel ?? '', kategorie: vorlage?.kategorie ?? 'SONSTIGES', @@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({ beschreibung: vorlage?.beschreibung ?? null, gegenstelle: null, aktenzeichen: null, + ansprechpartnerId: null, verantwortlich: benutzer.name, naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null, faelligAm: null, diff --git a/src/components/kontakte/kontakt-formular.tsx b/src/components/kontakte/kontakt-formular.tsx index 24e0003..40e44ba 100644 --- a/src/components/kontakte/kontakt-formular.tsx +++ b/src/components/kontakte/kontakt-formular.tsx @@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Feld, Feldgruppe } from '@/components/feld' -import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' +import { + KONTAKT_ANREDEN, + KONTAKT_ROLLEN, + KONTAKT_ROLLE_BESCHRIFTUNG, +} from '@/lib/validierung/kontakte' +import { ANREDE_BESCHRIFTUNG } from '@/lib/kontakte/anrede' import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte' /** @@ -24,6 +29,7 @@ export type KontaktWerte = { name: string organisation: string | null rolle: string + anrede: string telefon: string | null mobil: string | null fax: string | null @@ -91,6 +97,21 @@ export function KontaktFormular({ + + + + ('BRIEF') const [betreffKopiert, setBetreffKopiert] = React.useState(false) diff --git a/src/components/vorgaenge/vorgangs-formular.tsx b/src/components/vorgaenge/vorgangs-formular.tsx index 0444185..584a3e1 100644 --- a/src/components/vorgaenge/vorgangs-formular.tsx +++ b/src/components/vorgaenge/vorgangs-formular.tsx @@ -36,6 +36,7 @@ export type VorgangsFormularWerte = { beschreibung: string | null gegenstelle: string | null aktenzeichen: string | null + ansprechpartnerId: string | null verantwortlich: string | null naechsterSchritt: string | null faelligAm: Date | null @@ -50,6 +51,7 @@ export function VorgangsFormular({ vorlageSchluessel, werte, abbrechenHref, + kontakte, }: { modus: 'anlegen' | 'aendern' betreuteId: string @@ -57,6 +59,8 @@ export function VorgangsFormular({ vorlageSchluessel?: string | null werte: VorgangsFormularWerte abbrechenHref: string + /** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */ + kontakte: { id: string; name: string; organisation: string | null }[] }) { const [zustand, aktion, laeuft] = useActionState( modus === 'anlegen' ? vorgangAnlegen : vorgangAendern, @@ -146,6 +150,22 @@ export function VorgangsFormular({ /> + + + + = { + OHNE: 'keine Angabe', + FRAU: 'Frau', + HERR: 'Herr', +} + +export const ALLGEMEINE_ANREDE = 'Sehr geehrte Damen und Herren,' + +/** + * Der Nachname aus einem vollen Namen. + * + * Titel und Vornamen fallen weg: "Dr. Anna Schmitt" wird zu "Schmitt". + * Doppelnamen mit Bindestrich bleiben ganz, weil sie ein Wort sind. + */ +export function nachnameAus(name: string): string { + const teile = name.trim().split(/\s+/).filter(Boolean) + return teile.length > 0 ? teile[teile.length - 1]! : '' +} + +export type Ansprechpartner = { + name: string + anrede: KontaktAnrede +} + +/** + * Die Anredezeile fuer ein Schreiben, einschliesslich Komma. + * + * Ohne Ansprechpartner - oder mit einem, dessen Name leer ist - die + * allgemeine Anrede. + */ +export function anredeZeile(ansprechpartner: Ansprechpartner | null): string { + if (!ansprechpartner) return ALLGEMEINE_ANREDE + + const name = ansprechpartner.name.trim() + if (!name) return ALLGEMEINE_ANREDE + + if (ansprechpartner.anrede === 'FRAU') return `Sehr geehrte Frau ${nachnameAus(name)},` + if (ansprechpartner.anrede === 'HERR') return `Sehr geehrter Herr ${nachnameAus(name)},` + + return `Sehr geehrte(r) ${name},` +} diff --git a/src/lib/schreiben/entwurf.ts b/src/lib/schreiben/entwurf.ts index 4301ec0..7d93309 100644 --- a/src/lib/schreiben/entwurf.ts +++ b/src/lib/schreiben/entwurf.ts @@ -201,8 +201,19 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise = { RECHTSPFLEGER: 'Rechtspfleger', RICHTER: 'Richter', @@ -177,6 +184,7 @@ export const KontaktSchema = z.object({ rolle: z.enum(KONTAKT_ROLLEN, { errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }), }), + anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'), telefon: optionalerText(50), mobil: optionalerText(50), fax: optionalerText(50), @@ -198,6 +206,7 @@ export function kontaktFormularLesen(formular: FormData): Record .transform((wert) => (wert === '' ? null : wert)) .nullable() +/** Ein Verweis auf einen anderen Datensatz - leer heisst "keiner". */ +const optionaleId = z + .string() + .trim() + .transform((wert) => (wert === '' ? null : wert)) + .nullable() + .default(null) + const optionalesDatum = z .string() .trim() @@ -90,6 +98,7 @@ export const VorgangSchema = z.object({ beschreibung: optionalerText(4000), gegenstelle: optionalerText(150), aktenzeichen: optionalerText(80), + ansprechpartnerId: optionaleId, verantwortlich: optionalerText(120), naechsterSchritt: optionalerText(300), faelligAm: optionalesDatum, diff --git a/src/lib/version.ts b/src/lib/version.ts index 27b5899..f68a246 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.8.0' +export const VERSION = '1.9.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/lib/vorgaenge/sachstandsanfrage.ts b/src/lib/vorgaenge/sachstandsanfrage.ts index 1f22438..107c923 100644 --- a/src/lib/vorgaenge/sachstandsanfrage.ts +++ b/src/lib/vorgaenge/sachstandsanfrage.ts @@ -1,4 +1,5 @@ import { differenceInCalendarDays } from 'date-fns' +import { anredeZeile, type Ansprechpartner } from '@/lib/kontakte/anrede' import { formatDatum } from '@/lib/utils' /** @@ -36,8 +37,12 @@ export type SachstandsanfrageEingabe = { aktenzeichenGericht?: string | null betreuungsgericht?: string | null } - /** Ansprechpartner bei der Gegenstelle, wenn bekannt. */ - ansprechpartner?: string | null + /** + * Ansprechpartner bei der Gegenstelle, wenn am Vorgang benannt. + * Bestimmt die Anrede - ohne ihn bleibt es bei "Sehr geehrte Damen + * und Herren". + */ + ansprechpartner?: Ansprechpartner | null /** Für den Test überschreibbar. */ heute?: Date } @@ -69,13 +74,15 @@ export function sachstandsanfrageErzeugen( .filter(Boolean) .join(' · ') - const anrede = eingabe.ansprechpartner - ? `Sehr geehrte(r) ${eingabe.ansprechpartner},` - : 'Sehr geehrte Damen und Herren,' + const anrede = anredeZeile(eingabe.ansprechpartner ?? null) const zeilen: string[] = [] - if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle, '') + // Der Name steht ueber der Stelle, nicht darunter: So sortiert die + // Poststelle den Brief zur Person und nicht ins allgemeine Fach. + if (eingabe.ansprechpartner?.name.trim()) zeilen.push(eingabe.ansprechpartner.name.trim()) + if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle) + if (eingabe.ansprechpartner?.name.trim() || eingabe.gegenstelle) zeilen.push('') zeilen.push(formatDatum(heute), '') zeilen.push(`Betreff: ${betreff}`, '') zeilen.push(anrede, '') diff --git a/src/server/actions/vorgaenge.ts b/src/server/actions/vorgaenge.ts index b1cfdcf..bb337a7 100644 --- a/src/server/actions/vorgaenge.ts +++ b/src/server/actions/vorgaenge.ts @@ -5,7 +5,7 @@ import { redirect } from 'next/navigation' import type { Prisma } from '@prisma/client' import { db, nichtGeloescht } from '@/lib/db' import { erfordereZugang } from '@/lib/auth/sitzung' -import { aenderungErmitteln, protokollieren } from '@/lib/audit' +import { aenderungErmitteln, protokollieren, type Aenderung } from '@/lib/audit' import { oeffnen, sealen } from '@/lib/crypto/feld' import { AufgabeAbhakenSchema, @@ -58,6 +58,45 @@ function pfadeAuffrischen(vorgangId?: string): void { // Anlegen // ----------------------------------------------------------------------------- +/** Der Name eines Kontakts fuers Protokoll - null bleibt null. */ +async function kontaktnameZu(kontaktId: string | null): Promise { + if (!kontaktId) return null + const kontakt = await db.kontakt.findUnique({ + where: { id: kontaktId }, + select: { name: true, organisation: true }, + }) + if (!kontakt) return null + return [kontakt.name, kontakt.organisation].filter(Boolean).join(', ') +} + +/** + * Gehoert der Ansprechpartner zu dieser Akte? + * + * Ohne die Pruefung koennte ein veraendertes Formular einen Kontakt aus + * einer fremden Akte an den Vorgang haengen - und sein Name stuende + * anschliessend im Briefkopf. + */ +async function ansprechpartnerPruefen( + betreuteId: string, + ansprechpartnerId: string | null, +): Promise<{ ok: true } | { ok: false; zustand: Formularzustand }> { + if (!ansprechpartnerId) return { ok: true } + + const kontakt = await db.kontakt.findFirst({ + where: { id: ansprechpartnerId, betreuteId, geloeschtAm: null }, + select: { id: true }, + }) + if (kontakt) return { ok: true } + + return { + ok: false, + zustand: { + fehler: 'Diesen Kontakt gibt es nicht (mehr).', + felder: { ansprechpartnerId: 'Bitte einen Kontakt aus dem Verzeichnis wählen.' }, + }, + } +} + export async function vorgangAnlegen( _vorher: Formularzustand, formular: FormData, @@ -79,6 +118,10 @@ export async function vorgangAnlegen( } const eingabe = gelesen.data + + const geprueft = await ansprechpartnerPruefen(betreuteId, eingabe.ansprechpartnerId) + if (!geprueft.ok) return geprueft.zustand + const jetzt = new Date() // Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles @@ -101,6 +144,7 @@ export async function vorgangAnlegen( prioritaet: eingabe.prioritaet, gegenstelle: eingabe.gegenstelle, aktenzeichen: eingabe.aktenzeichen, + ansprechpartnerId: eingabe.ansprechpartnerId, verantwortlich: eingabe.verantwortlich ?? benutzer.name, naechsterSchritt: eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null, @@ -205,6 +249,10 @@ export async function vorgangAendern( } const eingabe = gelesen.data + + const geprueft = await ansprechpartnerPruefen(vorgang.betreuteId, eingabe.ansprechpartnerId) + if (!geprueft.ok) return geprueft.zustand + const vorherOffen = oeffnen('Vorgang', vorgang) const daten: Prisma.VorgangUpdateInput = { @@ -214,6 +262,11 @@ export async function vorgangAendern( prioritaet: eingabe.prioritaet, gegenstelle: eingabe.gegenstelle, aktenzeichen: eingabe.aktenzeichen, + // Ueber die Beziehung, nicht ueber das Feld: connect/disconnect ist + // das, was Prisma bei einer Update-Eingabe erwartet. + ansprechpartner: eingabe.ansprechpartnerId + ? { connect: { id: eingabe.ansprechpartnerId } } + : { disconnect: true }, verantwortlich: eingabe.verantwortlich, naechsterSchritt: eingabe.naechsterSchritt, faelligAm: eingabe.faelligAm, @@ -227,6 +280,19 @@ export async function vorgangAendern( const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten }) + // Der Wechsel des Ansprechpartners gehoert mit NAMEN ins Protokoll. + // Zwei cuids nebeneinander sind protokolliert, aber nicht + // nachvollziehbar - und nachvollziehbar war die Anforderung. + const ansprechpartnerWechsel: Aenderung = + vorgang.ansprechpartnerId !== geaendert.ansprechpartnerId + ? { + ansprechpartner: { + alt: await kontaktnameZu(vorgang.ansprechpartnerId), + neu: await kontaktnameZu(geaendert.ansprechpartnerId), + }, + } + : {} + await protokollieren({ aktion: 'GEAENDERT', entitaet: 'Vorgang', @@ -238,6 +304,7 @@ export async function vorgangAendern( userName: kontext.benutzer.name, aenderung: { ...aenderungErmitteln(vorgang, geaendert), + ...ansprechpartnerWechsel, ...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null) ? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } } : {}), diff --git a/tests/anrede.test.ts b/tests/anrede.test.ts new file mode 100644 index 0000000..57bfea7 --- /dev/null +++ b/tests/anrede.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { ALLGEMEINE_ANREDE, anredeZeile, nachnameAus } from '@/lib/kontakte/anrede' + +/** + * Die Briefanrede. + * + * Zwei Zusagen werden hier gepruft: Es wird nie geraten, und ein halb + * gefuellter Name fuehrt nie zu einer verstuemmelten Anrede. + */ + +describe('Nachname', () => { + it('nimmt das letzte Wort', () => { + expect(nachnameAus('Anna Schmitt')).toBe('Schmitt') + }) + + it('lässt Titel und Vornamen weg', () => { + expect(nachnameAus('Dr. Anna Maria Schmitt')).toBe('Schmitt') + }) + + it('hält einen Doppelnamen zusammen', () => { + expect(nachnameAus('Anna Schmitt-Berger')).toBe('Schmitt-Berger') + }) + + it('verträgt überzählige Leerzeichen', () => { + expect(nachnameAus(' Anna Schmitt ')).toBe('Schmitt') + }) +}) + +describe('Anredezeile', () => { + it('redet eine Frau mit Nachnamen an', () => { + expect(anredeZeile({ name: 'Anna Schmitt', anrede: 'FRAU' })).toBe( + 'Sehr geehrte Frau Schmitt,', + ) + }) + + it('redet einen Herrn mit Nachnamen an', () => { + expect(anredeZeile({ name: 'Peter Vogel', anrede: 'HERR' })).toBe( + 'Sehr geehrter Herr Vogel,', + ) + }) + + it('rät nicht, wenn die Anrede fehlt', () => { + // Lieber die etwas steife Form als ein falsch geratenes "Frau". + expect(anredeZeile({ name: 'Kim Berger', anrede: 'OHNE' })).toBe( + 'Sehr geehrte(r) Kim Berger,', + ) + }) + + it('bleibt ohne Ansprechpartner allgemein', () => { + expect(anredeZeile(null)).toBe(ALLGEMEINE_ANREDE) + }) + + it('wird bei leerem Namen nicht verstümmelt', () => { + // Der Fehler, der hier nie passieren darf: "Sehr geehrte Frau ,". + expect(anredeZeile({ name: ' ', anrede: 'FRAU' })).toBe(ALLGEMEINE_ANREDE) + }) +}) diff --git a/tests/vorgaenge.test.ts b/tests/vorgaenge.test.ts index 2a64f5e..01c0ae9 100644 --- a/tests/vorgaenge.test.ts +++ b/tests/vorgaenge.test.ts @@ -481,8 +481,20 @@ describe('Sachstandsanfrage', () => { }) it('spricht einen bekannten Ansprechpartner direkt an', () => { - const anfrage = sachstandsanfrageErzeugen({ ...eingabe, ansprechpartner: 'Frau Schmidt' }) - expect(anfrage.text).toContain('Sehr geehrte(r) Frau Schmidt,') + const anfrage = sachstandsanfrageErzeugen({ + ...eingabe, + ansprechpartner: { name: 'Petra Schmidt', anrede: 'FRAU' }, + }) + expect(anfrage.text).toContain('Sehr geehrte Frau Schmidt,') + // Der Name steht ueber der Stelle - so landet der Brief bei ihr. + const zeilen = anfrage.text.split('\n') + expect(zeilen[0]).toBe('Petra Schmidt') + expect(zeilen[1]).toBe('Bezirk Oberfranken') + }) + + it('bleibt ohne Ansprechpartner bei der allgemeinen Anrede', () => { + const anfrage = sachstandsanfrageErzeugen(eingabe) + expect(anfrage.text).toContain('Sehr geehrte Damen und Herren,') }) it('hält Nachfassen erst nach sechs Wochen für angebracht', () => {