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:
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user