Schreiben erzeugen, ausgeben und ablegen

Die Anschreiben gab es bisher nur als Text zum Kopieren. Danach war
Handarbeit: in Word nachbauen, ausdrucken, abschicken - und noch einmal
von Hand als Dokument in die Akte legen. Der letzte Schritt wurde am
haeufigsten vergessen, und dann fehlt der Nachweis im Bericht.

Neu unter /schreiben/neu: bearbeiten, als Word-Datei herunterladen, in
die Akte legen, Wiedervorlage setzen - in einem Zug. Eine Seite fuer alle
Arten und alle drei Empfaengerwege, statt die Bearbeitung dreimal an
Vertrag, Vorgang und Freitext nachzubauen.

Der dritte Weg ist der eigentliche Zugewinn: Schreiben an eine Stelle,
zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung ist das
der Normalfall - man fragt ja an, um zu erfahren, ob dort ueberhaupt
etwas besteht. Aus der freien Anschrift laesst sich mit einem Klick ein
Kontakt anlegen.

Zwei Entscheidungen, die erklaert gehoeren:

Die Akte bekommt ein PDF, heruntergeladen wird .docx. Der
Objektspeicher nimmt nur PDF und Bilder an, geprueft an den Magic Bytes.
Eine .docx ist ein ZIP-Archiv; sie durchzulassen hiesse jedes ZIP
durchzulassen, und diese Pruefung ist eine bewusste Zusage der
Anwendung. Sachlich ist PDF fuer den Nachweis ohnehin richtiger: Es
aendert sich nicht mehr. Die Oberflaeche sagt dazu, dass eine spaeter in
Word geaenderte Fassung von der Akte abweicht.

Der Berichtssatz in berichte/docx.ts bleibt unangetastet. Er setzt einen
Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief braucht
Absender, Anschriftenfeld, Datumszeile und Grussformel. Beides in eine
Funktion zu zwingen haette beide Ausgaben schlechter gemacht. Der
Umsetzungshinweis im Issue ("muss nur angebunden werden") war insoweit
falsch - von mir selbst geschrieben.

Die vorhandenen Erzeuger (Rueckkaufswert, Kuendigung, Sachstandsanfrage)
bleiben unveraendert; ihr Wortlaut ist getestet und erprobt.

Geprueft: zehn Einheitstests, darunter der Gleichschritt der
Wiedervorlagefristen ueber zwei Module hinweg. Fuenf End-to-End-Tests
auf beiden Geraeteprofilen: Entwurf, Vertragsbezug, Word-Download,
Ablage samt Wiedervorlage, Kontakt aus freier Anschrift.

Refs #4
This commit is contained in:
Florian Wilde
2026-08-23 10:14:59 +02:00
parent de21f1c320
commit c21d03abf6
19 changed files with 2186 additions and 396 deletions
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { briefDocxErzeugen } from '@/lib/schreiben/docx'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
/**
* Das Schreiben als Word-Datei herunterladen.
*
* Warum eine Route und keine Server Action? Eine Action kann keinen
* Download ausloesen - sie muesste die Datei kodiert zurueckgeben und der
* Browser sie nachbauen. Eine Route liefert sie unmittelbar aus, mit
* richtigem Namen und Typ. Denselben Weg geht schon der Vollexport.
*
* POST und nicht GET, weil der bearbeitete Brieftext mitkommt und in eine
* Adresszeile weder hineinpasst noch hineingehoert: Er stuende sonst im
* Verlauf des Browsers und in jedem Zwischenspeicher.
*/
export const dynamic = 'force-dynamic'
const MIME_DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
export async function POST(anfrage: Request): Promise<Response> {
let formular: FormData
try {
formular = await anfrage.formData()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const betreuteId = String(formular.get('betreuteId') ?? '')
const rohArt = String(formular.get('art') ?? '')
const text = String(formular.get('text') ?? '')
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
const versandRoh = String(formular.get('versandAm') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
try {
// Zugriff wird hier geprueft und nicht nur auf der Seite davor: Eine
// Route laesst sich unmittelbar aufrufen.
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const datum = tagAusText(versandRoh) ?? heuteAlsTag()
const datei = await briefDocxErzeugen(text)
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, datum, 'docx')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': MIME_DOCX,
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
// Ein Schreiben aus einer Betreuungsakte gehoert in keinen
// Zwischenspeicher.
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+121
View File
@@ -0,0 +1,121 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, Scale } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { SchreibenEditor } from '@/components/schreiben/schreiben-editor'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { entwurfBauen } from '@/lib/schreiben/entwurf'
import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten'
export const metadata: Metadata = { title: 'Schreiben' }
export const dynamic = 'force-dynamic'
/**
* Ein Schreiben erzeugen, bearbeiten, ausgeben und ablegen.
*
* Eine Seite für alle Arten und alle drei Empfängerwege — statt die
* Bearbeitung dreimal an Vertrag, Vorgang und Freitext nachzubauen. Die
* bestehenden Seiten verlinken hierher.
*
* Der Fall ohne Bezug ist ausdrücklich vorgesehen: Am Anfang einer
* Betreuung schreibt man an Stellen, zu denen es noch keinen Datensatz
* gibt — man fragt ja gerade an, um zu erfahren, ob dort etwas besteht.
*/
export default async function SchreibenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const benutzer = await erfordereBenutzer('/schreiben/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
if (!darfSchreiben('DOKUMENTE')) redirect('/dokumente')
const text = (name: string): string => {
const wert = parameter[name]
return typeof wert === 'string' ? wert.trim() : ''
}
const rohArt = text('art')
const art = istSchreibenArt(rohArt) ? rohArt : 'FREI'
const eigenschaften = SCHREIBEN_EIGENSCHAFTEN[art]
let entwurf
try {
entwurf = await entwurfBauen({
betreuteId: aktive.id,
benutzerName: benutzer.name,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
})
} catch (ausnahme) {
// Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine
// Angabe, die nicht stimmt. Die Seite sagt es und bietet den freien
// Weg an, statt den Benutzer vor eine Fehlerseite zu setzen.
const meldung =
ausnahme instanceof Error ? ausnahme.message : 'Der Entwurf ließ sich nicht bauen.'
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf titel="Schreiben" beschreibung={eigenschaften.beschriftung} />
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>
{meldung} Du kannst stattdessen ein{' '}
<a href="/schreiben/neu?art=FREI" className="underline">
freies Schreiben
</a>{' '}
erzeugen.
</AlertDescription>
</Alert>
</div>
)
}
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true, strasse: true, plz: true, ort: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 200,
})
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel={eigenschaften.beschriftung}
beschreibung="Bearbeiten, als Word-Datei herunterladen, in die Akte legen — und eine Wiedervorlage setzen, damit das Warten nicht vergessen geht."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]}
/>
<SchreibenEditor
betreuteId={aktive.id}
art={art}
entwurf={{
betreff: entwurf.betreff,
text: entwurf.text,
empfaengerName: entwurf.empfaengerName,
wiedervorlageAm: entwurf.wiedervorlageAm,
}}
kontakte={kontakte}
vertragId={entwurf.vertragId}
vorgangId={entwurf.vorgangId}
fristBegruendung={eigenschaften.fristBegruendung}
warnungen={entwurf.warnungen}
/>
<Alert variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>
Der vorgeschlagene Wortlaut ist ein Entwurf, keine Rechtsberatung. Bei einem
genehmigungspflichtigen Geschäft gehört die Genehmigung des Betreuungsgerichts vor den
Versand — nicht danach.
</AlertDescription>
</Alert>
</div>
)
}
+387 -379
View File
@@ -1,379 +1,387 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<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>
)
}
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<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 && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{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>
)
}