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,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,
|
||||
})),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user