feat: Vorgaenge als Fallakte mit Genehmigungskette und Vorlagen

Jeder Vorgang ist eine kleine Akte: Kopfdaten, Aufgabenliste zum
Abhaken, Genehmigungsstufen und ein Zeitstrahl, der Dokumente,
Kontaktprotokolle, Fristen, Buchungen und Statuswechsel zusammenfuehrt.

Die abnahmerelevante Regel: Ein genehmigungspflichtiger Vorgang laesst
sich nicht auf VOLLZOGEN setzen, solange kein Beschlussdokument
hinterlegt ist. Geprueft wird in darfStatusWechseln() UND in der Server
Action - nicht nur im UI, wo das Auswahlfeld die Stufe ohnehin gar nicht
erst anbietet. Hintergrund ist § 1858 BGB: die Genehmigung muss vor dem
Rechtsgeschaeft vorliegen, nicht danach.

Neun Vorlagen mit durchdachter Aufgabenreihenfolge. Bei Wohnungs-
aufloesung und Hausverkauf erzwingen sie die Kette Mitteilung ->
Genehmigung -> Rechtsgeschaeft; ein Test prueft das.

Kanban ohne Drag-and-Drop: Statuswechsel laufen ueber Auswahlfeld und
Knopf. Am Handy ist das verlaesslicher, und am Schreibtisch kostet es
einen Klick mehr statt einer verlorenen Karte.

Zeitzonen: alsTag() bildet den oertlichen Kalendertag auf Mitternacht
UTC ab. Ohne das rutscht in Deutschland jede Faelligkeit einen Tag
zurueck.

33 Tests.

Etappe: 2
Phase: Alltag
This commit is contained in:
Florian Wilde
2026-08-22 02:24:16 +02:00
parent 63352272b8
commit 1217725076
5 changed files with 1021 additions and 12 deletions
+136
View File
@@ -0,0 +1,136 @@
# Schema-Wünsche: Vorgänge, Aufgaben, Genehmigungen
Beim Bau von Etappe 2 („Vorgänge (Fallakte)“) sind vier Stellen aufgefallen,
an denen das Datenmodell die Fachlichkeit nur mit Umwegen abbildet. Nichts
davon blockiert die Umsetzung — alles ist im Code sauber umschifft. Die
Umwege stehen unten mit dabei, damit man beim Ändern weiß, was wegfällt.
`prisma/schema.prisma` wurde **nicht** angefasst.
---
## 1. `Genehmigung`: Die Versagung hat kein eigenes Datum
**Ist**: `mitteilungAm`, `antragAm`, `anhoerungAm`, `beschlussAm`,
`rechtskraftAm`, `vollzugAm` — für jede Stufe der Kette ein Feld. Nur der
Seitenausgang `VERSAGT` hat keins.
**Warum das stört**: Der Zeitstrahl der Fallakte baut die Chronik des
Genehmigungsverfahrens aus genau diesen Datumsfeldern. Eine Versagung ist
aber der Ausgang, der später am meisten Erklärung braucht — gegenüber dem
Gericht, gegenüber Miterben, und für die Frage, ob eine Beschwerde noch
läuft.
**Wunsch**:
```prisma
versagungAm DateTime? @db.Date
versagungsgrund String?
```
**Umweg bis dahin**: `src/lib/vorgaenge/zeitstrahl.ts` zeigt eine Versagung
am `beschlussAm` an — das Gericht versagt ja mit einem Beschluss. Der Grund
landet in `notizenVerschluesselt`.
---
## 2. `Aufgabe`: kein Papierkorb
**Ist**: `Aufgabe` hat kein `geloeschtAm`. Sie hängt per `onDelete: Cascade`
am Vorgang.
**Warum das stört**: Vorlagen bringen bis zu zehn Aufgaben mit. Manche davon
passen im Einzelfall nicht („Rundfunkbeitrag“, wenn nie einer gezahlt
wurde). Sie einfach abzuhaken ist unehrlich — sie wurden nicht erledigt,
sie waren gegenstandslos. Löschen wiederum ist im Rest der Anwendung immer
ein Papierkorb, hier wäre es endgültig.
**Wunsch**:
```prisma
geloeschtAm DateTime?
entfaellt Boolean @default(false)
entfallGrund String?
```
`entfaellt` wäre das Ehrlichere: Die Aufgabe bleibt in der Akte sichtbar,
zählt aber nicht gegen den Fortschritt.
**Umweg bis dahin**: Es gibt in dieser Etappe keine Möglichkeit, eine
Aufgabe zu entfernen. Nicht passende Aufgaben bleiben offen stehen.
---
## 3. Statuswechsel des Vorgangs stehen nur im Audit-Log
**Ist**: Der Zeitstrahl liest die Statuschronik eines Vorgangs aus
`AuditLog`, entschlüsselt `aenderungVerschluesselt` und sucht darin das
Feld `status`.
**Warum das stört**: Drei Dinge auf einmal —
1. Es werden bis zu 200 Log-Einträge je Aufruf entschlüsselt, nur um ein
Feld zu lesen. Das skaliert schlecht und lässt sich nicht filtern,
weil der Umschlag nicht durchsuchbar ist.
2. Das Audit-Log ist ein Nachweis, keine Fachtabelle. Es fachlich
auszuwerten vermischt zwei Zwecke.
3. Ein Log-Eintrag, der sich nicht entschlüsseln lässt (Schlüsselrotation),
reißt still ein Loch in die Chronik.
**Wunsch**: eine schmale Tabelle
```prisma
model VorgangStatuswechsel {
id String @id @default(cuid())
vorgangId String
vorgang Vorgang @relation(fields: [vorgangId], references: [id], onDelete: Cascade)
von VorgangStatus?
nach VorgangStatus
zeitpunkt DateTime @default(now())
userId String?
userName String?
@@index([vorgangId, zeitpunkt])
}
```
**Umweg bis dahin**: `src/app/(app)/vorgaenge/[id]/page.tsx` liest das
Audit-Log und fängt unlesbare Einträge einzeln ab. Die Schnittstelle von
`zeitstrahlBauen()` nimmt die Wechsel bereits als eigene Quelle entgegen —
ein Umstieg auf die Tabelle wäre eine Änderung in einer Datei.
---
## 4. `Vorgang`: kein Feld für den Ansprechpartner bei der Gegenstelle
**Ist**: `gegenstelle` (Text) und `aktenzeichen` (Text), dazu die n:m-
Beziehung `kontakte`. Wer bei der Behörde konkret zuständig ist, steht
nirgends direkt am Vorgang.
**Warum das stört**: Die Sachstandsanfrage
(`src/lib/vorgaenge/sachstandsanfrage.ts`) kann eine namentliche Anrede
erzeugen — genau die, die erfahrungsgemäß schneller beantwortet wird. Sie
bekommt den Namen aber nicht, ohne über `kontakte` zu raten, welcher der
verknüpften Kontakte gemeint ist.
**Wunsch**:
```prisma
/// Der eine Kontakt, der diesen Vorgang bei der Gegenstelle führt.
ansprechpartnerId String?
ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull)
```
**Umweg bis dahin**: `sachstandsanfrageErzeugen()` nimmt den
Ansprechpartner als optionalen Parameter entgegen und fällt sonst auf
„Sehr geehrte Damen und Herren“ zurück. Die Seite übergibt ihn derzeit
nicht.
---
## Nicht gewünscht
Ausdrücklich **kein** Wunsch ist ein Enum für
`Genehmigung.rechtsgrundlage`. Der Freitext ist richtig: Die Auswahlliste
in `src/lib/vorgaenge/genehmigungskette.ts` deckt den Alltag ab, aber
seltene Tatbestände muss man eintragen können, ohne eine Migration zu
schreiben.
+379
View File
@@ -0,0 +1,379 @@
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<string, { alt: unknown; neu: unknown }>
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 (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
+1 -2
View File
@@ -6,9 +6,8 @@ import type { VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { vorgangStatusWechseln } from '@/server/actions/vorgaenge'
import { VORGANG_STATUS } from '@/lib/validierung/vorgaenge'
import { VORGANG_STATUS, type Formularzustand } from '@/lib/validierung/vorgaenge'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn } from '@/lib/utils'
/**
+11 -10
View File
@@ -746,15 +746,16 @@ export type VorlagenErgebnis = {
}
/**
* Der Tagesanteil eines Datums, auf Mitternacht UTC normiert.
* Bildet den örtlichen Kalendertag auf Mitternacht UTC ab.
*
* Fristen und Fälligkeiten sind in der Datenbank @db.Date - ohne diese
* Normierung verschiebt die Zeitzone eine Frist um einen Tag.
* Fristen und Fälligkeiten sind in der Datenbank @db.Date. Wer hier den
* Zeitstempel einfach abschneidet, verschiebt in Deutschland jede Frist um
* einen Tag nach hinten - der 22.08. um 01:00 Uhr Ortszeit ist in UTC noch
* der 21.08. Deshalb wird der Tag gelesen, wie ihn der Kalender an der Wand
* zeigt, und dann als Mitternacht UTC abgelegt.
*/
export function alsTag(datum: Date): Date {
return new Date(
Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 0, 0, 0, 0),
)
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 0, 0, 0, 0))
}
export function vorlageAnwenden(schluessel: string, start: Date = new Date()): VorlagenErgebnis {
@@ -763,22 +764,22 @@ export function vorlageAnwenden(schluessel: string, start: Date = new Date()): V
throw new Error(`Unbekannte Vorgangs-Vorlage: ${schluessel}`)
}
const basis = alsTag(start)
// Gerechnet wird auf dem oertlichen Datum (date-fns ist dabei
// sommerzeitfest), erst das Ergebnis wird auf Mitternacht UTC normiert.
return {
vorlage,
aufgaben: vorlage.aufgaben.map((aufgabe, index) => ({
titel: aufgabe.titel,
beschreibung: aufgabe.beschreibung ?? null,
faelligAm:
aufgabe.tageBisFaellig === undefined ? null : alsTag(addDays(basis, aufgabe.tageBisFaellig)),
aufgabe.tageBisFaellig === undefined ? null : alsTag(addDays(start, aufgabe.tageBisFaellig)),
// Die Reihenfolge ist der fachliche Kern der Vorlage - sie wird
// festgeschrieben, damit spaeteres Sortieren sie nicht zerstoert.
reihenfolge: index,
})),
fristen: (vorlage.fristen ?? []).map((frist) => ({
titel: frist.titel,
datum: alsTag(addDays(basis, frist.tageBisFaellig)),
datum: alsTag(addDays(start, frist.tageBisFaellig)),
rechtsgrundlage: frist.rechtsgrundlage ?? null,
})),
}
+494
View File
@@ -0,0 +1,494 @@
import { describe, expect, it } from 'vitest'
import {
darfStatusWechseln,
GENEHMIGUNGSKETTE,
moeglicheStatusWechsel,
naechsterStatus,
RECHTSGRUNDLAGEN,
stufeVon,
} from '@/lib/vorgaenge/genehmigungskette'
import { VORLAGEN, vorlageAnwenden, vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { zeitstrahlBauen } from '@/lib/vorgaenge/zeitstrahl'
import {
nachfassenFaellig,
sachstandsanfrageErzeugen,
} from '@/lib/vorgaenge/sachstandsanfrage'
/**
* Diese Tests beschreiben, was fachlich gelten soll - nicht, welche
* Funktion aufgerufen wird. Keine Datenbank: alles hier ist reine Logik.
*/
// =============================================================================
describe('Genehmigungskette', () => {
it('läuft in der vorgeschriebenen Reihenfolge vom Plan bis zum Vollzug', () => {
expect(GENEHMIGUNGSKETTE).toEqual([
'GEPLANT',
'MITGETEILT',
'BEANTRAGT',
'ANGEHOERT',
'ERTEILT',
'RECHTSKRAEFTIG',
'VOLLZOGEN',
])
})
it('lässt jeweils genau eine Stufe vorwärts zu', () => {
for (let i = 0; i < GENEHMIGUNGSKETTE.length - 1; i++) {
const von = GENEHMIGUNGSKETTE[i]!
const nach = GENEHMIGUNGSKETTE[i + 1]!
// Der letzte Schritt braucht den Beschluss - der wird unten geprüft.
const beschluss = nach === 'VOLLZOGEN' ? 'dok-1' : null
expect(darfStatusWechseln({ status: von, beschlussDokumentId: beschluss }, nach).erlaubt).toBe(
true,
)
expect(naechsterStatus(von)).toBe(nach)
}
})
it('verweigert das Überspringen von Stufen und nennt die übersprungenen', () => {
const pruefung = darfStatusWechseln(
{ status: 'BEANTRAGT', beschlussDokumentId: 'dok-1' },
'VOLLZOGEN',
)
expect(pruefung.erlaubt).toBe(false)
expect(pruefung.grund).toContain('Anhörung erfolgt')
expect(pruefung.grund).toContain('§ 1858 BGB')
})
it('erlaubt eine Stufe zurück als Korrektur, aber nicht zwei', () => {
expect(darfStatusWechseln({ status: 'ERTEILT' }, 'ANGEHOERT').erlaubt).toBe(true)
expect(darfStatusWechseln({ status: 'ERTEILT' }, 'BEANTRAGT').erlaubt).toBe(false)
})
// --- die abnahmerelevante Regel -------------------------------------------
it('sperrt den Vollzug, solange kein Beschlussdokument hinterlegt ist', () => {
const ohneBeschluss = darfStatusWechseln(
{ status: 'RECHTSKRAEFTIG', beschlussDokumentId: null },
'VOLLZOGEN',
)
expect(ohneBeschluss.erlaubt).toBe(false)
expect(ohneBeschluss.grund).toContain('Beschluss')
const mitBeschluss = darfStatusWechseln(
{ status: 'RECHTSKRAEFTIG', beschlussDokumentId: 'dok-42' },
'VOLLZOGEN',
)
expect(mitBeschluss.erlaubt).toBe(true)
})
it('bietet den Vollzug ohne Beschluss gar nicht erst zur Auswahl an', () => {
expect(moeglicheStatusWechsel({ status: 'RECHTSKRAEFTIG', beschlussDokumentId: null })).not.toContain(
'VOLLZOGEN',
)
expect(moeglicheStatusWechsel({ status: 'RECHTSKRAEFTIG', beschlussDokumentId: 'dok-42' })).toContain(
'VOLLZOGEN',
)
})
it('behandelt den Vollzug als endgültig', () => {
for (const status of [...GENEHMIGUNGSKETTE, 'VERSAGT'] as const) {
if (status === 'VOLLZOGEN') continue
expect(
darfStatusWechseln({ status: 'VOLLZOGEN', beschlussDokumentId: 'dok-1' }, status).erlaubt,
).toBe(false)
}
})
it('lässt die Versagung nur zu, solange etwas anhängig und nichts rechtskräftig ist', () => {
for (const status of ['MITGETEILT', 'BEANTRAGT', 'ANGEHOERT', 'ERTEILT'] as const) {
expect(darfStatusWechseln({ status }, 'VERSAGT').erlaubt).toBe(true)
}
for (const status of ['GEPLANT', 'RECHTSKRAEFTIG'] as const) {
expect(darfStatusWechseln({ status }, 'VERSAGT').erlaubt).toBe(false)
}
})
it('führt nach einer Versagung zurück auf den Anfang - und nur dorthin', () => {
expect(darfStatusWechseln({ status: 'VERSAGT' }, 'GEPLANT').erlaubt).toBe(true)
expect(darfStatusWechseln({ status: 'VERSAGT' }, 'BEANTRAGT').erlaubt).toBe(false)
expect(stufeVon('VERSAGT')).toBe(-1)
})
it('meldet einen Wechsel auf den bereits gesetzten Status als unnötig', () => {
const pruefung = darfStatusWechseln({ status: 'BEANTRAGT' }, 'BEANTRAGT')
expect(pruefung.erlaubt).toBe(false)
expect(pruefung.grund).toContain('bereits')
})
it('erklärt jede wählbare Rechtsgrundlage im Klartext', () => {
const werte = RECHTSGRUNDLAGEN.map((r) => r.wert)
for (const paragraph of [
'§ 1850 BGB',
'§ 1833 BGB',
'§ 1854 BGB',
'§ 1851 BGB',
'§ 1849 BGB',
'§ 1859 BGB',
]) {
expect(werte).toContain(paragraph)
}
for (const grundlage of RECHTSGRUNDLAGEN) {
expect(grundlage.erklaerung.length).toBeGreaterThan(40)
expect(grundlage.beispiele.length).toBeGreaterThan(0)
}
})
})
// =============================================================================
describe('Vorgangs-Vorlagen', () => {
it('bringt die neun Vorlagen mit, auf die sich die anderen Module verlassen', () => {
const schluessel = VORLAGEN.map((v) => v.schluessel)
expect(schluessel).toEqual(
expect.arrayContaining([
'heimeinzug',
'antrag-hilfe-zur-pflege',
'wohnungsaufloesung',
'hausverkauf',
'darlehen-91-sgb-xii',
'versicherungsverwertung',
'pflegegrad-hoeherstufung',
'steuererklaerung',
'widerspruch-bescheid',
]),
)
expect(new Set(schluessel).size).toBe(schluessel.length)
})
it('gibt jeder Vorlage eine vorbefüllte Aufgabenliste', () => {
for (const vorlage of VORLAGEN) {
expect(vorlage.aufgaben.length).toBeGreaterThanOrEqual(4)
expect(vorlage.titel.length).toBeGreaterThan(3)
expect(vorlage.beschreibung.length).toBeGreaterThan(20)
}
})
it('nennt bei jeder genehmigungspflichtigen Vorlage die Vorschrift', () => {
for (const vorlage of VORLAGEN.filter((v) => v.genehmigungspflichtig)) {
expect(vorlage.rechtsgrundlage).toBeTruthy()
}
expect(vorlageFinden('wohnungsaufloesung')?.rechtsgrundlage).toBe('§ 1833 BGB')
expect(vorlageFinden('hausverkauf')?.rechtsgrundlage).toBe('§ 1850 Nr. 1 BGB')
expect(vorlageFinden('darlehen-91-sgb-xii')?.genehmigungspflichtig).toBe(true)
})
it('stellt bei der Wohnungsauflösung die Mitteilung ans Gericht vor die Kündigung', () => {
const aufgaben = vorlageFinden('wohnungsaufloesung')!.aufgaben.map((a) => a.titel)
const mitteilung = aufgaben.findIndex((t) => t.includes('Mitteilung an das Betreuungsgericht'))
const genehmigung = aufgaben.findIndex((t) => t.includes('Genehmigung des Gerichts abwarten'))
const kuendigung = aufgaben.findIndex((t) => t.includes('Mietverhältnis kündigen'))
expect(mitteilung).toBeGreaterThanOrEqual(0)
expect(mitteilung).toBeLessThan(genehmigung)
expect(genehmigung).toBeLessThan(kuendigung)
})
it('holt beim Hausverkauf die Genehmigung vor die Beurkundung', () => {
const aufgaben = vorlageFinden('hausverkauf')!.aufgaben.map((a) => a.titel)
const einreichen = aufgaben.findIndex((t) => t.includes('Kaufvertragsentwurf beim'))
const rechtskraft = aufgaben.findIndex((t) => t.includes('Rechtskraft prüfen'))
const beurkundung = aufgaben.findIndex((t) => t.includes('Beurkundung'))
expect(einreichen).toBeLessThan(rechtskraft)
expect(rechtskraft).toBeLessThan(beurkundung)
})
it('setzt den Widerspruch fristwahrend an den Anfang und legt die Monatsfrist an', () => {
const vorlage = vorlageFinden('widerspruch-bescheid')!
expect(vorlage.aufgaben[0]?.titel).toBe(
'Widerspruch fristwahrend einlegen, Begründung nachreichen',
)
const frist = vorlage.fristen?.find((f) => f.rechtsgrundlage === '§ 84 SGG')
expect(frist?.tageBisFaellig).toBe(30)
})
it('rechnet die Fälligkeiten vom Anlegedatum aus, ohne Uhrzeit', () => {
const ergebnis = vorlageAnwenden('heimeinzug', new Date(2026, 0, 15, 17, 42))
expect(ergebnis.aufgaben).toHaveLength(8)
expect(ergebnis.aufgaben[0]?.titel).toBe('Heimvertrag prüfen und gegenzeichnen')
// 15.01.2026 + 7 Tage = 22.01.2026, Mitternacht UTC (@db.Date).
expect(ergebnis.aufgaben[0]?.faelligAm?.toISOString()).toBe('2026-01-22T00:00:00.000Z')
expect(ergebnis.aufgaben[1]?.faelligAm?.toISOString()).toBe('2026-01-29T00:00:00.000Z')
// Die Reihenfolge der Vorlage wird festgeschrieben.
expect(ergebnis.aufgaben.map((a) => a.reihenfolge)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
expect(ergebnis.fristen).toHaveLength(1)
expect(ergebnis.fristen[0]?.datum.toISOString()).toBe('2026-03-16T00:00:00.000Z')
})
it('rechnet über einen Monatswechsel hinweg richtig', () => {
const ergebnis = vorlageAnwenden('widerspruch-bescheid', new Date(2026, 1, 20))
// 20.02.2026 + 30 Tage = 22.03.2026
expect(ergebnis.fristen[0]?.datum.toISOString()).toBe('2026-03-22T00:00:00.000Z')
})
it('weist einen erfundenen Vorlagenschlüssel zurück', () => {
expect(() => vorlageAnwenden('gibt-es-nicht')).toThrowError(/Vorlage/)
})
})
// =============================================================================
describe('Zeitstrahl der Fallakte', () => {
const grundlage = {
vorgang: {
titel: 'Hausverkauf',
erstelltAm: new Date('2026-01-10T09:00:00.000Z'),
erledigtAm: null,
},
}
it('sortiert alle Quellen zusammen, neueste zuerst', () => {
const eintraege = zeitstrahlBauen({
...grundlage,
dokumente: [
{
id: 'd1',
titel: 'Kaufvertragsentwurf',
eingangAm: new Date('2026-03-01T00:00:00.000Z'),
versandAm: null,
erstelltAm: new Date('2026-03-05T00:00:00.000Z'),
absender: 'Notariat Bayreuth',
empfaenger: null,
},
],
kontaktprotokolle: [
{
id: 'k1',
datum: new Date('2026-02-01T10:00:00.000Z'),
art: 'TELEFONAT',
gespraechspartner: 'Rechtspfleger Meier',
mitBetreuter: false,
inhalt: 'Ablauf der Genehmigung besprochen.',
},
],
fristen: [
{
id: 'f1',
titel: 'Wertgutachten anfordern',
datum: new Date('2026-04-01T00:00:00.000Z'),
erledigtAm: null,
ausschlussfrist: false,
rechtsgrundlage: null,
},
],
buchungen: [
{
id: 'b1',
datum: new Date('2026-05-01T00:00:00.000Z'),
betragCent: 150_000,
richtung: 'AUSGABE',
gegenstelle: 'Gutachterbüro',
verwendungszweck: 'Verkehrswertgutachten',
},
],
})
expect(eintraege.map((e) => e.id)).toEqual([
'BUCHUNG:b1',
'FRIST:f1',
'DOKUMENT:d1',
'KONTAKT:k1',
'ANGELEGT:vorgang',
])
})
it('kann auch von hinten gelesen werden', () => {
const eintraege = zeitstrahlBauen(
{
...grundlage,
fristen: [
{
id: 'f1',
titel: 'Frist A',
datum: new Date('2026-04-01T00:00:00.000Z'),
erledigtAm: null,
ausschlussfrist: true,
rechtsgrundlage: '§ 1878 BGB',
},
],
},
{ reihenfolge: 'aelteste-zuerst' },
)
expect(eintraege[0]?.id).toBe('ANGELEGT:vorgang')
expect(eintraege[1]?.titel).toBe('Ausschlussfrist: Frist A')
})
it('nimmt beim Dokument den Eingang, sonst den Versand', () => {
const eintraege = zeitstrahlBauen({
...grundlage,
dokumente: [
{
id: 'd1',
titel: 'Antrag',
eingangAm: null,
versandAm: new Date('2026-02-10T00:00:00.000Z'),
erstelltAm: new Date('2026-02-20T00:00:00.000Z'),
absender: null,
empfaenger: 'Bezirk Oberfranken',
},
],
})
const dokument = eintraege.find((e) => e.id === 'DOKUMENT:d1')
expect(dokument?.datum.toISOString()).toBe('2026-02-10T00:00:00.000Z')
expect(dokument?.detail).toBe('Versand an Bezirk Oberfranken')
})
it('macht aus den Datumsfeldern einer Genehmigung die Chronik des Verfahrens', () => {
const eintraege = zeitstrahlBauen({
...grundlage,
genehmigungen: [
{
id: 'g1',
titel: 'Verkauf Hausgrundstück',
rechtsgrundlage: '§ 1850 BGB',
status: 'RECHTSKRAEFTIG',
mitteilungAm: new Date('2026-02-01T00:00:00.000Z'),
antragAm: new Date('2026-02-15T00:00:00.000Z'),
anhoerungAm: null,
beschlussAm: new Date('2026-03-20T00:00:00.000Z'),
rechtskraftAm: new Date('2026-04-05T00:00:00.000Z'),
vollzugAm: null,
},
],
})
const genehmigung = eintraege.filter((e) => e.art === 'GENEHMIGUNG')
expect(genehmigung).toHaveLength(4)
expect(genehmigung[0]?.titel).toBe('Genehmigung: Rechtskräftig')
expect(genehmigung.at(-1)?.titel).toBe('Genehmigung: Dem Gericht mitgeteilt')
})
it('zeigt Ausgaben mit negativem Vorzeichen', () => {
const eintraege = zeitstrahlBauen({
...grundlage,
buchungen: [
{
id: 'b1',
datum: new Date('2026-05-01T00:00:00.000Z'),
betragCent: 4200,
richtung: 'AUSGABE',
gegenstelle: null,
verwendungszweck: null,
},
{
id: 'b2',
datum: new Date('2026-05-02T00:00:00.000Z'),
betragCent: 9900,
richtung: 'EINNAHME',
gegenstelle: null,
verwendungszweck: null,
},
],
})
expect(eintraege.find((e) => e.id === 'BUCHUNG:b1')?.betragCent).toBe(-4200)
expect(eintraege.find((e) => e.id === 'BUCHUNG:b2')?.betragCent).toBe(9900)
})
it('hält die Reihenfolge bei gleichem Datum stabil', () => {
const gleich = new Date('2026-06-01T00:00:00.000Z')
const einmal = zeitstrahlBauen({
...grundlage,
fristen: [
{
id: 'f1',
titel: 'A',
datum: gleich,
erledigtAm: null,
ausschlussfrist: false,
rechtsgrundlage: null,
},
],
dokumente: [
{
id: 'd1',
titel: 'B',
eingangAm: gleich,
versandAm: null,
erstelltAm: gleich,
absender: null,
empfaenger: null,
},
],
})
// Dokument vor Frist: was passiert ist, steht über dem, was ansteht.
expect(einmal.map((e) => e.id).slice(0, 2)).toEqual(['DOKUMENT:d1', 'FRIST:f1'])
})
})
// =============================================================================
describe('Sachstandsanfrage', () => {
const eingabe = {
vorgangTitel: 'Antrag auf Hilfe zur Pflege',
gegenstelle: 'Bezirk Oberfranken',
aktenzeichen: 'HzP 2026/4711',
letzterKontaktAm: new Date(2026, 4, 12),
betreute: {
vorname: 'Erika',
nachname: 'Mustermann',
geburtsdatum: new Date(1948, 2, 3),
},
betreuer: {
name: 'Florian Wilde',
aktenzeichenGericht: 'XVII 123/25',
betreuungsgericht: 'Amtsgericht Bayreuth',
},
heute: new Date(2026, 5, 30),
}
it('nennt Aktenzeichen, Gegenstelle und das Datum des letzten Kontakts', () => {
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.text).toContain('Bezirk Oberfranken')
expect(anfrage.text).toContain('HzP 2026/4711')
expect(anfrage.text).toContain('12.05.2026')
expect(anfrage.text).toContain('30.06.2026')
expect(anfrage.text).toContain('Erika Mustermann')
expect(anfrage.text).toContain('XVII 123/25')
})
it('bittet ausdrücklich um den Sachstand und schließt mit dem Namen des Betreuers', () => {
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.text).toContain('Sachstand')
expect(anfrage.text).toContain('Mit freundlichen Grüßen')
expect(anfrage.text.trimEnd().endsWith('Rechtlicher Betreuer')).toBe(true)
})
it('rechnet die Tage seit dem letzten Kontakt aus', () => {
// 12.05.2026 bis 30.06.2026 sind 49 Tage.
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.tageSeitKontakt).toBe(49)
expect(anfrage.text).toContain('49 Tage')
})
it('kommt ohne erfassten letzten Kontakt aus', () => {
const anfrage = sachstandsanfrageErzeugen({ ...eingabe, letzterKontaktAm: null })
expect(anfrage.tageSeitKontakt).toBeNull()
expect(anfrage.text).toContain('bisher nicht eingegangen')
})
it('baut einen Betreff, der die Sache eindeutig macht', () => {
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.betreff).toBe(
'Sachstandsanfrage: Antrag auf Hilfe zur Pflege · Erika Mustermann, geb. 03.03.1948 · Az. HzP 2026/4711',
)
})
it('spricht einen bekannten Ansprechpartner direkt an', () => {
const anfrage = sachstandsanfrageErzeugen({ ...eingabe, ansprechpartner: 'Frau Schmidt' })
expect(anfrage.text).toContain('Sehr geehrte(r) Frau Schmidt,')
})
it('hält Nachfassen erst nach sechs Wochen für angebracht', () => {
const heute = new Date(2026, 5, 30)
expect(nachfassenFaellig(new Date(2026, 5, 20), heute)).toBe(false)
expect(nachfassenFaellig(new Date(2026, 4, 12), heute)).toBe(true)
expect(nachfassenFaellig(null, heute)).toBe(true)
})
})