Feat #9: Ansprechpartner am Vorgang hinterlegen #20

Merged
florian.wilde merged 1 commits from feat/9-ansprechpartner-am-vorgang into main 2026-08-23 14:55:49 +00:00
23 changed files with 651 additions and 31 deletions
Showing only changes of commit 9e68d10787 - Show all commits
+59 -1
View File
@@ -26,6 +26,63 @@ Wiederherstellung*.
_Noch nichts._ _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 ## [1.8.0] – 2026-08-23
### Hinzugefügt ### 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.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.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 [1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0
+7 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.8.0 git checkout v1.9.0
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.8.0 git describe --tags # v1.9.0
``` ```
--- ---
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.8.0","commit":"..."} # {"status":"bereit","version":"1.9.0","commit":"..."}
``` ```
Kommt hier nichts, sieh in die Logs: Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.8.0",...} # {"status":"bereit","version":"1.9.0",...}
``` ```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.8.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.9.0 · <commit>`.
--- ---
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung | | Aufruf | Wirkung |
|---|---| |---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.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 --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/main
# 3. Umschalten # 3. Umschalten
git checkout v1.8.0 git checkout v1.9.0
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben. als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.8.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.9.0** ([CHANGELOG](../CHANGELOG.md))
--- ---
+164
View File
@@ -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<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.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 <details>.
const protokoll = (await page.locator('body').textContent()) ?? ''
expect(protokoll).toContain('ansprechpartner')
expect(protokoll).toContain(`${KENNUNG} Petra Sachs`)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
@@ -0,0 +1,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;
+23 -4
View File
@@ -373,6 +373,14 @@ model Vorgang {
gegenstelle String? gegenstelle String?
aktenzeichen 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? verantwortlich String?
angelegtVonId String? angelegtVonId String?
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
@@ -1233,6 +1241,13 @@ model Vertrag {
// Kontakte und Kontaktprotokoll // Kontakte und Kontaktprotokoll
// ============================================================================= // =============================================================================
/// Anrede fuer die Briefanrede eines Kontakts.
enum KontaktAnrede {
OHNE
FRAU
HERR
}
enum KontaktRolle { enum KontaktRolle {
RECHTSPFLEGER RECHTSPFLEGER
RICHTER RICHTER
@@ -1267,7 +1282,10 @@ model Kontakt {
name String name String
organisation 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? telefon String?
mobil String? mobil String?
@@ -1285,9 +1303,10 @@ model Kontakt {
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime? geloeschtAm DateTime?
protokolle Kontaktprotokoll[] protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte") vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[] betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle]) @@index([betreuteId, rolle])
@@map("kontakt") @@map("kontakt")
@@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({
name: kontakt.name, name: kontakt.name,
organisation: kontakt.organisation, organisation: kontakt.organisation,
rolle: kontakt.rolle, rolle: kontakt.rolle,
anrede: kontakt.anrede,
telefon: kontakt.telefon, telefon: kontakt.telefon,
mobil: kontakt.mobil, mobil: kontakt.mobil,
fax: kontakt.fax, fax: kontakt.fax,
+1
View File
@@ -126,6 +126,7 @@ export default async function SchreibenSeite({
betreff: entwurf.betreff, betreff: entwurf.betreff,
text: entwurf.text, text: entwurf.text,
empfaengerName: entwurf.empfaengerName, empfaengerName: entwurf.empfaengerName,
empfaengerAnschrift: entwurf.empfaengerAnschrift,
wiedervorlageAm: entwurf.wiedervorlageAm, wiedervorlageAm: entwurf.wiedervorlageAm,
}} }}
kontakte={kontakte} kontakte={kontakte}
+51
View File
@@ -56,6 +56,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } }, verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
}, },
}) })
if (!vorgang) notFound() if (!vorgang) notFound()
@@ -65,6 +66,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Vorgang', vorgang) 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 -------------------------------------- // --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle. // Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
@@ -182,6 +191,12 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
aktenzeichenGericht: aktive.aktenzeichenGericht, aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht, betreuungsgericht: aktive.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
}) })
: null : null
@@ -285,6 +300,40 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p> <p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div> </div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p> <p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium"> <p className="font-medium">
@@ -363,6 +412,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
betreuteId={aktive.id} betreuteId={aktive.id}
vorgangId={vorgang.id} vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`} abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{ werte={{
titel: vorgang.titel, titel: vorgang.titel,
kategorie: vorgang.kategorie, kategorie: vorgang.kategorie,
@@ -371,6 +421,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
beschreibung: offen.beschreibung ?? null, beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle, gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen, aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich, verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt, naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm, faelligAm: vorgang.faelligAm,
+11
View File
@@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette' import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen' import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben') 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 auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1' const leer = auswahl.leer === '1'
@@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({
betreuteId={aktive.id} betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null} vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge" abbrechenHref="/vorgaenge"
kontakte={kontakte}
werte={{ werte={{
titel: vorlage?.titel ?? '', titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES', kategorie: vorlage?.kategorie ?? 'SONSTIGES',
@@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({
beschreibung: vorlage?.beschreibung ?? null, beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null, gegenstelle: null,
aktenzeichen: null, aktenzeichen: null,
ansprechpartnerId: null,
verantwortlich: benutzer.name, verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null, naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null, faelligAm: null,
+22 -1
View File
@@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' 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' import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/** /**
@@ -24,6 +29,7 @@ export type KontaktWerte = {
name: string name: string
organisation: string | null organisation: string | null
rolle: string rolle: string
anrede: string
telefon: string | null telefon: string | null
mobil: string | null mobil: string | null
fax: string | null fax: string | null
@@ -91,6 +97,21 @@ export function KontaktFormular({
</Select> </Select>
</Feld> </Feld>
<Feld
id="anrede"
beschriftung="Anrede"
erklaerung="Für die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet — geraten wird nie."
fehler={zustand.felder?.anrede}
>
<Select name="anrede" defaultValue={wert('anrede') || 'OHNE'}>
{KONTAKT_ANREDEN.map((anrede) => (
<option key={anrede} value={anrede}>
{ANREDE_BESCHRIFTUNG[anrede]}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen dort" beschriftung="Aktenzeichen dort"
+10 -2
View File
@@ -59,7 +59,13 @@ export function SchreibenEditor({
}: { }: {
betreuteId: string betreuteId: string
art: SchreibenArt art: SchreibenArt
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string } entwurf: {
betreff: string
text: string
empfaengerName: string
empfaengerAnschrift: string
wiedervorlageAm: string
}
kontakte: KontaktAuswahl[] kontakte: KontaktAuswahl[]
vertragId: string | null vertragId: string | null
vorgangId: string | null vorgangId: string | null
@@ -69,7 +75,9 @@ export function SchreibenEditor({
const [text, setText] = React.useState(entwurf.text) const [text, setText] = React.useState(entwurf.text)
const [betreff, setBetreff] = React.useState(entwurf.betreff) const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState(
entwurf.empfaengerAnschrift,
)
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('') const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF') const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false) const [betreffKopiert, setBetreffKopiert] = React.useState(false)
@@ -36,6 +36,7 @@ export type VorgangsFormularWerte = {
beschreibung: string | null beschreibung: string | null
gegenstelle: string | null gegenstelle: string | null
aktenzeichen: string | null aktenzeichen: string | null
ansprechpartnerId: string | null
verantwortlich: string | null verantwortlich: string | null
naechsterSchritt: string | null naechsterSchritt: string | null
faelligAm: Date | null faelligAm: Date | null
@@ -50,6 +51,7 @@ export function VorgangsFormular({
vorlageSchluessel, vorlageSchluessel,
werte, werte,
abbrechenHref, abbrechenHref,
kontakte,
}: { }: {
modus: 'anlegen' | 'aendern' modus: 'anlegen' | 'aendern'
betreuteId: string betreuteId: string
@@ -57,6 +59,8 @@ export function VorgangsFormular({
vorlageSchluessel?: string | null vorlageSchluessel?: string | null
werte: VorgangsFormularWerte werte: VorgangsFormularWerte
abbrechenHref: string abbrechenHref: string
/** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */
kontakte: { id: string; name: string; organisation: string | null }[]
}) { }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>( const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern, modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
@@ -146,6 +150,22 @@ export function VorgangsFormular({
/> />
</Feld> </Feld>
<Feld
id="ansprechpartnerId"
beschriftung="Ansprechpartner"
erklaerung="Wer dort die Sache bearbeitet. Schreiben zu diesem Vorgang reden ihn namentlich an — das bringt sie an der Poststelle vorbei."
fehler={zustand.felder?.ansprechpartnerId}
>
<Select name="ansprechpartnerId" defaultValue={werte.ansprechpartnerId ?? ''}>
<option value="">– keiner benannt –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{[kontakt.name, kontakt.organisation].filter(Boolean).join(' · ')}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen" beschriftung="Aktenzeichen"
+61
View File
@@ -0,0 +1,61 @@
import type { KontaktAnrede } from '@prisma/client'
/**
* Die Briefanrede eines Ansprechpartners.
*
* Ein namentlich adressiertes Schreiben landet bei der Person, die den
* Vorgang kennt, statt in der Poststelle. Beim Bezirk Oberfranken ist das
* der Unterschied zwischen zwei Wochen und zwei Monaten.
*
* Zwei Regeln, die hier nicht verhandelbar sind:
*
* 1. Es wird nie geraten. Ohne hinterlegte Anrede wird mit dem vollen
* Namen angeredet ("Sehr geehrte(r) Anna Schmitt,"). Eine falsch
* geratene Anrede faellt dem Empfaenger sofort auf und kostet genau
* die Aufmerksamkeit, die man gewinnen wollte.
* 2. Ohne Ansprechpartner bleibt es bei "Sehr geehrte Damen und Herren,".
* Ein halb gefuellter Name darf nie zu "Sehr geehrte Frau ," werden -
* lieber unpersoenlich als offensichtlich automatisch.
*/
export const ANREDE_BESCHRIFTUNG: Record<KontaktAnrede, string> = {
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},`
}
+30 -3
View File
@@ -201,8 +201,19 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const vorgang = await db.vorgang.findFirst({ const vorgang = await db.vorgang.findFirst({
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht }, where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
// Der benannte Ansprechpartner, nicht irgendeiner der verknuepften
// Kontakte: Wer angeredet wird, ist eine Entscheidung und kein Zufall.
include: { include: {
kontakte: { select: { name: true, organisation: true }, take: 1 }, ansprechpartner: {
select: {
name: true,
anrede: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
},
},
}, },
}) })
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).') if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
@@ -222,15 +233,31 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
aktenzeichenGericht: betreute.aktenzeichenGericht, aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht, betreuungsgericht: betreute.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner,
heute, heute,
}) })
// Ist ein Ansprechpartner benannt, kommt seine Anschrift gleich mit -
// sie steht im Kontaktverzeichnis und muesste sonst abgetippt werden.
const anschrift = vorgang.ansprechpartner
? [
vorgang.ansprechpartner.strasse,
[vorgang.ansprechpartner.plz, vorgang.ansprechpartner.ort]
.filter(Boolean)
.join(' ')
.trim() || null,
]
.filter(Boolean)
.join('\n')
: ''
return { return {
...gemeinsam, ...gemeinsam,
text: anfrage.text, text: anfrage.text,
betreff: anfrage.betreff, betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '', empfaengerName:
empfaengerAnschrift: '', vorgang.ansprechpartner?.name ?? vorgang.gegenstelle ?? '',
empfaengerAnschrift: anschrift,
} }
} }
+10 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import type { KontaktArt, KontaktRolle } from '@prisma/client' import type { KontaktAnrede, KontaktArt, KontaktRolle } from '@prisma/client'
/** /**
* Eingabepruefung fuer Kontakte und Kontaktprotokolle. * Eingabepruefung fuer Kontakte und Kontaktprotokolle.
@@ -101,6 +101,13 @@ export const KONTAKT_ROLLEN = [
'SONSTIGES', 'SONSTIGES',
] as const satisfies readonly KontaktRolle[] ] as const satisfies readonly KontaktRolle[]
/** Anreden fuer die Briefanrede - Reihenfolge der Auswahlliste. */
export const KONTAKT_ANREDEN = [
'OHNE',
'FRAU',
'HERR',
] as const satisfies readonly KontaktAnrede[]
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = { export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
RECHTSPFLEGER: 'Rechtspfleger', RECHTSPFLEGER: 'Rechtspfleger',
RICHTER: 'Richter', RICHTER: 'Richter',
@@ -177,6 +184,7 @@ export const KontaktSchema = z.object({
rolle: z.enum(KONTAKT_ROLLEN, { rolle: z.enum(KONTAKT_ROLLEN, {
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }), errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
}), }),
anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'),
telefon: optionalerText(50), telefon: optionalerText(50),
mobil: optionalerText(50), mobil: optionalerText(50),
fax: optionalerText(50), fax: optionalerText(50),
@@ -198,6 +206,7 @@ export function kontaktFormularLesen(formular: FormData): Record<string, unknown
for (const feld of [ for (const feld of [
'name', 'name',
'organisation', 'organisation',
'anrede',
'rolle', 'rolle',
'telefon', 'telefon',
'mobil', 'mobil',
+9
View File
@@ -19,6 +19,14 @@ const optionalerText = (max = 200) =>
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .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 const optionalesDatum = z
.string() .string()
.trim() .trim()
@@ -90,6 +98,7 @@ export const VorgangSchema = z.object({
beschreibung: optionalerText(4000), beschreibung: optionalerText(4000),
gegenstelle: optionalerText(150), gegenstelle: optionalerText(150),
aktenzeichen: optionalerText(80), aktenzeichen: optionalerText(80),
ansprechpartnerId: optionaleId,
verantwortlich: optionalerText(120), verantwortlich: optionalerText(120),
naechsterSchritt: optionalerText(300), naechsterSchritt: optionalerText(300),
faelligAm: optionalesDatum, faelligAm: optionalesDatum,
+1 -1
View File
@@ -17,7 +17,7 @@
*/ */
/** Muss zur "version" in der package.json passen. */ /** 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: * Kurzer Git-Stand, beim Bauen hineingereicht:
+13 -6
View File
@@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns' import { differenceInCalendarDays } from 'date-fns'
import { anredeZeile, type Ansprechpartner } from '@/lib/kontakte/anrede'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
/** /**
@@ -36,8 +37,12 @@ export type SachstandsanfrageEingabe = {
aktenzeichenGericht?: string | null aktenzeichenGericht?: string | null
betreuungsgericht?: 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. */ /** Für den Test überschreibbar. */
heute?: Date heute?: Date
} }
@@ -69,13 +74,15 @@ export function sachstandsanfrageErzeugen(
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
const anrede = eingabe.ansprechpartner const anrede = anredeZeile(eingabe.ansprechpartner ?? null)
? `Sehr geehrte(r) ${eingabe.ansprechpartner},`
: 'Sehr geehrte Damen und Herren,'
const zeilen: string[] = [] 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(formatDatum(heute), '')
zeilen.push(`Betreff: ${betreff}`, '') zeilen.push(`Betreff: ${betreff}`, '')
zeilen.push(anrede, '') zeilen.push(anrede, '')
+68 -1
View File
@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang } from '@/lib/auth/sitzung' 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 { oeffnen, sealen } from '@/lib/crypto/feld'
import { import {
AufgabeAbhakenSchema, AufgabeAbhakenSchema,
@@ -58,6 +58,45 @@ function pfadeAuffrischen(vorgangId?: string): void {
// Anlegen // Anlegen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Der Name eines Kontakts fuers Protokoll - null bleibt null. */
async function kontaktnameZu(kontaktId: string | null): Promise<string | null> {
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( export async function vorgangAnlegen(
_vorher: Formularzustand, _vorher: Formularzustand,
formular: FormData, formular: FormData,
@@ -79,6 +118,10 @@ export async function vorgangAnlegen(
} }
const eingabe = gelesen.data const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
const jetzt = new Date() const jetzt = new Date()
// Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles // Aus der Vorlage entstehen Aufgaben und Fristen in einem Rutsch. Alles
@@ -101,6 +144,7 @@ export async function vorgangAnlegen(
prioritaet: eingabe.prioritaet, prioritaet: eingabe.prioritaet,
gegenstelle: eingabe.gegenstelle, gegenstelle: eingabe.gegenstelle,
aktenzeichen: eingabe.aktenzeichen, aktenzeichen: eingabe.aktenzeichen,
ansprechpartnerId: eingabe.ansprechpartnerId,
verantwortlich: eingabe.verantwortlich ?? benutzer.name, verantwortlich: eingabe.verantwortlich ?? benutzer.name,
naechsterSchritt: naechsterSchritt:
eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null, eingabe.naechsterSchritt ?? ergebnis?.aufgaben[0]?.titel ?? null,
@@ -205,6 +249,10 @@ export async function vorgangAendern(
} }
const eingabe = gelesen.data const eingabe = gelesen.data
const geprueft = await ansprechpartnerPruefen(vorgang.betreuteId, eingabe.ansprechpartnerId)
if (!geprueft.ok) return geprueft.zustand
const vorherOffen = oeffnen('Vorgang', vorgang) const vorherOffen = oeffnen('Vorgang', vorgang)
const daten: Prisma.VorgangUpdateInput = { const daten: Prisma.VorgangUpdateInput = {
@@ -214,6 +262,11 @@ export async function vorgangAendern(
prioritaet: eingabe.prioritaet, prioritaet: eingabe.prioritaet,
gegenstelle: eingabe.gegenstelle, gegenstelle: eingabe.gegenstelle,
aktenzeichen: eingabe.aktenzeichen, 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, verantwortlich: eingabe.verantwortlich,
naechsterSchritt: eingabe.naechsterSchritt, naechsterSchritt: eingabe.naechsterSchritt,
faelligAm: eingabe.faelligAm, faelligAm: eingabe.faelligAm,
@@ -227,6 +280,19 @@ export async function vorgangAendern(
const geaendert = await db.vorgang.update({ where: { id: vorgang.id }, data: daten }) 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({ await protokollieren({
aktion: 'GEAENDERT', aktion: 'GEAENDERT',
entitaet: 'Vorgang', entitaet: 'Vorgang',
@@ -238,6 +304,7 @@ export async function vorgangAendern(
userName: kontext.benutzer.name, userName: kontext.benutzer.name,
aenderung: { aenderung: {
...aenderungErmitteln(vorgang, geaendert), ...aenderungErmitteln(vorgang, geaendert),
...ansprechpartnerWechsel,
...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null) ...(vorherOffen.beschreibung !== (eingabe.beschreibung ?? null)
? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } } ? { beschreibung: { alt: vorherOffen.beschreibung, neu: eingabe.beschreibung } }
: {}), : {}),
+57
View File
@@ -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)
})
})
+14 -2
View File
@@ -481,8 +481,20 @@ describe('Sachstandsanfrage', () => {
}) })
it('spricht einen bekannten Ansprechpartner direkt an', () => { it('spricht einen bekannten Ansprechpartner direkt an', () => {
const anfrage = sachstandsanfrageErzeugen({ ...eingabe, ansprechpartner: 'Frau Schmidt' }) const anfrage = sachstandsanfrageErzeugen({
expect(anfrage.text).toContain('Sehr geehrte(r) Frau Schmidt,') ...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', () => { it('hält Nachfassen erst nach sechs Wochen für angebracht', () => {