feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+107
View File
@@ -0,0 +1,107 @@
import Link from 'next/link'
import { ReceiptText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelZeile } from '@/components/dashboard/kachel'
import type { BelegeDaten } from '@/lib/dashboard/typen'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Belege und Buchungen, die noch nicht zusammengefunden haben.
*
* Fuer die Rechnungslegung gilt Belegzwang: zu jeder Buchung gehoert ein
* Beleg, und wo keiner existiert, gehoert eine Begruendung dazu. Diese
* Kachel zeigt beide Luecken - Belege ohne Buchung und Buchungen ohne
* Beleg.
*/
export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
if (!daten) return null
const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt
return (
<Kachel
titel="Unverbuchte Belege"
icon={ReceiptText}
zaehler={offen}
hinweis={
offen > 0
? 'Zu jeder Buchung gehört ein Beleg – oder eine Begründung, warum keiner existiert.'
: undefined
}
aktion={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen?filter=ohne-beleg">Kassenbuch öffnen</Link>
</Button>
}
>
{offen === 0 ? (
<Leerzustand
icon={ReceiptText}
titel="Belege und Buchungen passen zusammen."
beschreibung="Jede erfasste Buchung hat einen Beleg oder eine Begründung, und keine Rechnung wartet auf ihre Buchung."
className="py-8"
/>
) : (
<div className="space-y-5">
{daten.belegeOhneBuchungGesamt > 0 && (
<section aria-labelledby="belege-ohne-buchung">
<h3
id="belege-ohne-buchung"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Rechnungen und Quittungen ohne Buchung ({daten.belegeOhneBuchungGesamt})
</h3>
<ul className="divide-y">
{daten.belegeOhneBuchung.map((beleg) => (
<li key={beleg.id}>
<KachelZeile href={`/finanzen/buchungen/neu?beleg=${beleg.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">{beleg.titel}</span>
<span className="block text-sm text-muted-foreground">
{beleg.eingangAm
? `Eingang ${formatDatum(beleg.eingangAm)}`
: 'ohne Eingangsdatum'}{' '}
· Buchung anlegen
</span>
</span>
</KachelZeile>
</li>
))}
</ul>
</section>
)}
{daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg">
<h3
id="buchungen-ohne-beleg"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Buchungen ohne Beleg ({daten.buchungenOhneBelegGesamt})
</h3>
<ul className="divide-y">
{daten.buchungenOhneBeleg.map((buchung) => (
<li key={buchung.id}>
<KachelZeile href={`/finanzen/buchungen/${buchung.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">
{buchung.richtung === 'AUSGABE' ? '−' : '+'}
{formatCent(buchung.betragCent)}
{buchung.gegenstelle ? ` · ${buchung.gegenstelle}` : ''}
</span>
<span className="block text-sm text-muted-foreground">
{formatDatum(buchung.datum)} · Beleg zuordnen oder begründen
</span>
</span>
</KachelZeile>
</li>
))}
</ul>
</section>
)}
</div>
)}
</Kachel>
)
}
+172
View File
@@ -0,0 +1,172 @@
import Link from 'next/link'
import { AlertTriangle, CalendarClock } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { KachelWeg } from '@/components/dashboard/kachel'
import { fristLage, verfallwarnung } from '@/lib/dashboard/bewertung'
import type { Ampelstufe, FristEintrag, FristenAmpelDaten } from '@/lib/dashboard/typen'
import { cn, formatDatum } from '@/lib/utils'
/**
* Die Fristen-Ampel - die Kachel, die immer ganz oben steht.
*
* Reihenfolge ist Dringlichkeit: ueberfaellig, diese Woche, dieser Monat.
* Farbe ist nie die einzige Information; neben jedem Punkt steht, wie
* viele Tage es sind.
*/
const STUFEN_TITEL: Record<Exclude<Ampelstufe, 'gruen'>, string> = {
rot: 'Überfällig',
orange: 'Diese Woche fällig',
gelb: 'Diesen Monat fällig',
}
const STUFEN_PUNKT: Record<Exclude<Ampelstufe, 'gruen'>, string> = {
rot: 'bg-ampel-rot',
orange: 'bg-ampel-orange',
gelb: 'bg-ampel-gelb',
}
export function FristenAmpel({ daten }: { daten: FristenAmpelDaten | null }) {
if (!daten) return null
const gruppen: { stufe: Exclude<Ampelstufe, 'gruen'>; eintraege: FristEintrag[] }[] = [
{ stufe: 'rot', eintraege: daten.ueberfaellig },
{ stufe: 'orange', eintraege: daten.dieseWoche },
{ stufe: 'gelb', eintraege: daten.diesenMonat },
]
const etwasFaellig = gruppen.some((g) => g.eintraege.length > 0)
const ueberfaellig = daten.ueberfaellig.length
return (
<Card className={cn(ueberfaellig > 0 && 'border-ampel-rot/60')}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<CalendarClock
className={cn('size-5', ueberfaellig > 0 ? 'text-ampel-rot' : 'text-muted-foreground')}
aria-hidden="true"
/>
Fristen
</CardTitle>
<div className="flex flex-wrap gap-1.5">
{daten.ueberfaellig.length > 0 && (
<Badge variant="rot">{daten.ueberfaellig.length} überfällig</Badge>
)}
{daten.dieseWoche.length > 0 && (
<Badge variant="orange">{daten.dieseWoche.length} diese Woche</Badge>
)}
{daten.diesenMonat.length > 0 && (
<Badge variant="gelb">{daten.diesenMonat.length} diesen Monat</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Ausschlussfristen zuerst - sie verfallen ersatzlos. */}
{daten.ausschlussfristen.map((frist) => (
<Alert key={`ausschluss-${frist.id}`} variant="fehler">
<AlertTriangle aria-hidden="true" />
<AlertTitle>
Ausschlussfrist: {frist.titel} – {formatDatum(frist.datum)}
</AlertTitle>
<AlertDescription>
<p>{verfallwarnung(frist)}</p>
<p className="mt-2">
<Button asChild size="sm" variant="destructive">
<Link href={`/fristen?frist=${frist.id}`}>Frist jetzt bearbeiten</Link>
</Button>
</p>
</AlertDescription>
</Alert>
))}
{!etwasFaellig && (
<Leerzustand
icon={CalendarClock}
titel={
daten.gesamtOffen > 0
? 'Diesen Monat ist nichts fällig.'
: 'Es sind noch keine Fristen erfasst.'
}
beschreibung={
daten.gesamtOffen > 0
? `${daten.weitereOffen} offene ${
daten.weitereOffen === 1 ? 'Frist liegt' : 'Fristen liegen'
} weiter in der Zukunft.`
: 'Aus dem Bestellungsdatum lassen sich die gesetzlichen Pflichtfristen ableiten – Anfangsbericht, Vermögensverzeichnis, Jahresbericht, Aufwandspauschale.'
}
aktion={
<Button asChild>
<Link href="/fristen">Pflichtfristen ableiten</Link>
</Button>
}
/>
)}
{gruppen.map(
(gruppe) =>
gruppe.eintraege.length > 0 && (
<section key={gruppe.stufe} aria-labelledby={`fristen-${gruppe.stufe}`}>
<h3
id={`fristen-${gruppe.stufe}`}
className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
{STUFEN_TITEL[gruppe.stufe]} ({gruppe.eintraege.length})
</h3>
<ul className="divide-y">
{gruppe.eintraege.map((frist) => (
<li key={frist.id}>
<Link
href={frist.vorgangId ? `/vorgaenge/${frist.vorgangId}` : '/fristen'}
className={cn(
'flex min-h-11 items-start gap-3 py-2.5 transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring',
)}
>
<span
aria-hidden="true"
className={cn(
'mt-2 size-2.5 shrink-0 rounded-full',
STUFEN_PUNKT[gruppe.stufe],
)}
/>
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="block text-sm text-muted-foreground">
{formatDatum(frist.datum)} · {fristLage(frist.tage)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && (
<Badge variant="rot" className="shrink-0">
Ausschlussfrist
</Badge>
)}
</Link>
</li>
))}
</ul>
</section>
),
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<KachelWeg href="/fristen">Alle Fristen ansehen</KachelWeg>
{daten.weitereOffen > 0 && etwasFaellig && (
<span className="text-sm text-muted-foreground">
{daten.weitereOffen} weitere offene{' '}
{daten.weitereOffen === 1 ? 'Frist' : 'Fristen'} später im Jahr
</span>
)}
</div>
</CardContent>
</Card>
)
}
+106
View File
@@ -0,0 +1,106 @@
import * as React from 'react'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
/**
* Der gemeinsame Rahmen aller Dashboard-Kacheln.
*
* Regel fuer jede Kachel: Sie endet in einer Handlung. Der Fuss ist kein
* schmueckendes "mehr anzeigen", sondern der naechste Handgriff - deshalb
* ist er Pflicht.
*/
export function Kachel({
titel,
icon: Icon,
zaehler,
dringend,
hinweis,
aktion,
children,
className,
}: {
titel: string
icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
/** Zahl neben der Ueberschrift, z.B. offene Vorgaenge. */
zaehler?: number
/** Faerbt den Rahmen und den Zaehler rot. */
dringend?: boolean
hinweis?: React.ReactNode
aktion: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<Card className={cn(dringend && 'border-ampel-rot/50', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<Icon
className={cn('size-5 shrink-0', dringend ? 'text-ampel-rot' : 'text-muted-foreground')}
aria-hidden={true}
/>
{titel}
</CardTitle>
{zaehler !== undefined && zaehler > 0 && (
<span
className={cn(
'min-w-7 rounded-full px-2 py-0.5 text-center text-sm font-semibold tabular-nums',
dringend ? 'bg-ampel-rot text-white' : 'bg-muted text-muted-foreground',
)}
>
{zaehler}
</span>
)}
</div>
{hinweis && <div className="text-sm text-muted-foreground">{hinweis}</div>}
</CardHeader>
<CardContent className="space-y-4">
{children}
<div className="pt-1">{aktion}</div>
</CardContent>
</Card>
)
}
/** Der Fussweg aus einer Kachel heraus - immer ein konkreter Handgriff. */
export function KachelWeg({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className={cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-md text-sm font-medium text-primary',
'hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{children}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
)
}
/** Eine Zeile in einer Kachel: anklickbar, mindestens 44 px hoch. */
export function KachelZeile({
href,
children,
className,
}: {
href: string
children: React.ReactNode
className?: string
}) {
return (
<Link
href={href}
className={cn(
'flex min-h-11 items-start gap-3 rounded-md border border-transparent px-2 py-2 -mx-2',
'transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring',
className,
)}
>
{children}
</Link>
)
}
+191
View File
@@ -0,0 +1,191 @@
import * as React from 'react'
import Link from 'next/link'
import { Banknote, HeartPulse, ScrollText } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { fristLage, monatsname } from '@/lib/dashboard/bewertung'
import type { KennzahlenDaten } from '@/lib/dashboard/typen'
import { cn, formatCent, formatDatum } from '@/lib/utils'
/**
* Die drei Zahlen, die den Stand der Betreuung beschreiben: Vermoegen,
* Heimkosten, naechster Jahresbericht.
*
* Fehlt eine Zahl, steht hier nicht "0", sondern der Weg dorthin - eine
* frische Installation hat noch keine Wertstaende, und eine Null waere an
* dieser Stelle schlicht falsch.
*/
export function Kennzahlen({ daten }: { daten: KennzahlenDaten }) {
const etwasSichtbar =
daten.vermoegen !== null || daten.heimkosten !== null || daten.jahresbericht !== null
if (!etwasSichtbar) return null
return (
<section aria-labelledby="kennzahlen" className="space-y-3">
<h2 id="kennzahlen" className="text-lg font-semibold">
Wo stehen wir?
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{daten.vermoegen !== null && (
<Kennzahl
icon={Banknote}
titel="Vermögen"
href="/finanzen"
wegweiser="Zum Vermögensverzeichnis"
>
{daten.vermoegen.postenGesamt === 0 ? (
<Fehlanzeige text="Es ist noch kein Vermögensposten erfasst. Konten, Versicherungen und Verbindlichkeiten gehören ins Vermögensverzeichnis." />
) : daten.vermoegen.stichtag === null ? (
<Fehlanzeige
text={`${daten.vermoegen.postenGesamt} Posten sind angelegt, aber zu keinem ist ein Wert zum Stichtag erfasst.`}
/>
) : (
<>
<p className="text-2xl font-semibold tabular-nums">
{formatCent(daten.vermoegen.nettoCent)}
</p>
<p className="text-sm text-muted-foreground">
Stand {formatDatum(daten.vermoegen.stichtag)}
</p>
<dl className="mt-2 space-y-0.5 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Vermögenswerte</dt>
<dd className="tabular-nums">{formatCent(daten.vermoegen.aktivaCent)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Verbindlichkeiten</dt>
<dd className="tabular-nums">
−{formatCent(daten.vermoegen.verbindlichkeitenCent)}
</dd>
</div>
</dl>
{daten.vermoegen.postenOhneWert > 0 && (
<p className="mt-2 text-sm text-ampel-orange">
{daten.vermoegen.postenOhneWert}{' '}
{daten.vermoegen.postenOhneWert === 1 ? 'Posten hat' : 'Posten haben'} noch
keinen Wert und {daten.vermoegen.postenOhneWert === 1 ? 'fehlt' : 'fehlen'} in
dieser Summe.
</p>
)}
</>
)}
</Kennzahl>
)}
{daten.heimkosten !== null && (
<Kennzahl
icon={HeartPulse}
titel="Heimkosten"
href="/pflege"
wegweiser="Heimkosten öffnen"
>
<p className="text-sm text-muted-foreground">
{monatsname(daten.heimkosten.monat)} {daten.heimkosten.jahr}
</p>
<p
className={cn(
'text-2xl font-semibold tabular-nums',
daten.heimkosten.deckungsluecke > 0 && 'text-ampel-rot',
)}
>
{daten.heimkosten.deckungsluecke > 0
? formatCent(daten.heimkosten.deckungsluecke)
: formatCent(0)}
</p>
<p className="text-sm text-muted-foreground">
{daten.heimkosten.deckungsluecke > 0
? 'ungedeckt – dieser Betrag muss aus dem Vermögen oder von der Sozialhilfe kommen'
: 'Die Kosten dieses Monats sind gedeckt.'}
</p>
<dl className="mt-2 space-y-0.5 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Kosten</dt>
<dd className="tabular-nums">{formatCent(daten.heimkosten.gesamtkostenCent)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Deckung</dt>
<dd className="tabular-nums">{formatCent(daten.heimkosten.deckungCent)}</dd>
</div>
</dl>
</Kennzahl>
)}
{daten.jahresbericht !== null ? (
<Kennzahl
icon={ScrollText}
titel="Nächster Jahresbericht"
href="/berichte"
wegweiser="Bericht vorbereiten"
>
<p
className={cn(
'text-2xl font-semibold tabular-nums',
daten.jahresbericht.tage < 0 && 'text-ampel-rot',
daten.jahresbericht.tage >= 0 && daten.jahresbericht.tage <= 30 && 'text-ampel-orange',
)}
>
{daten.jahresbericht.tage < 0
? `${Math.abs(daten.jahresbericht.tage)} Tage überfällig`
: `${daten.jahresbericht.tage} Tage`}
</p>
<p className="text-sm text-muted-foreground">
{daten.jahresbericht.titel} · fällig am {formatDatum(daten.jahresbericht.datum)} (
{fristLage(daten.jahresbericht.tage)})
</p>
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" kurz className="mt-2" />
</Kennzahl>
) : (
<Kennzahl
icon={ScrollText}
titel="Nächster Jahresbericht"
href="/fristen"
wegweiser="Pflichtfristen ableiten"
>
<Fehlanzeige text="Es ist keine Frist für den Jahresbericht erfasst. Sie ergibt sich aus dem Bestellungsdatum und lässt sich unter Fristen ableiten." />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" kurz className="mt-2" />
</Kennzahl>
)}
</div>
</section>
)
}
function Kennzahl({
icon: Icon,
titel,
href,
wegweiser,
children,
}: {
icon: React.ComponentType<{ className?: string }>
titel: string
href: string
wegweiser: string
children: React.ReactNode
}) {
return (
<Card>
<CardContent className="space-y-1 p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="size-4" />
{titel}
</h3>
{children}
<p className="pt-2">
<Link
href={href}
className="inline-flex min-h-11 items-center text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{wegweiser}
</Link>
</p>
</CardContent>
</Card>
)
}
function Fehlanzeige({ text }: { text: string }) {
return <p className="text-sm text-muted-foreground">{text}</p>
}
@@ -0,0 +1,119 @@
import Link from 'next/link'
import { Landmark } from 'lucide-react'
import type { NachforderungStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg } from '@/components/dashboard/kachel'
import { fristLage } from '@/lib/dashboard/bewertung'
import type { NachforderungenDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* Die Nachforderungen des Sozialhilfetraegers.
*
* Eigene, prominente Kachel: Solange eine Unterlage fehlt, entscheidet der
* Bezirk nicht - und die Heimrechnung laeuft weiter. Der Balken beantwortet
* auf einen Blick "wie weit bin ich?".
*/
const STATUS_BESCHRIFTUNG: Record<NachforderungStatus, string> = {
OFFEN: 'offen',
IN_BESCHAFFUNG: 'in Beschaffung',
UEBERSANDT: 'übersandt',
ANERKANNT: 'anerkannt',
NICHT_VORHANDEN: 'nicht vorhanden',
}
export function NachforderungenKachel({ daten }: { daten: NachforderungenDaten | null }) {
if (!daten) return null
const ziel = daten.antragId ? `/sozialhilfe?antrag=${daten.antragId}` : '/sozialhilfe'
if (daten.gesamt === 0) {
return (
<Kachel
titel="Nachforderungen des Trägers"
icon={Landmark}
aktion={<KachelWeg href="/sozialhilfe">Zum Sozialhilfeantrag</KachelWeg>}
>
<Leerzustand
icon={Landmark}
titel="Es sind keine Nachforderungen erfasst."
beschreibung="Fordert der Bezirk Unterlagen an, trage jede angeforderte Unterlage als eigene Zeile ein – dann zeigt der Balken hier, was noch fehlt."
className="py-8"
aktion={
<Button asChild variant="outline">
<Link href="/sozialhilfe">Unterlagen erfassen</Link>
</Button>
}
/>
</Kachel>
)
}
return (
<Kachel
titel="Nachforderungen des Trägers"
icon={Landmark}
zaehler={daten.offen.length}
dringend={daten.ueberfaellig > 0}
hinweis={
<>
{daten.traeger ?? 'Sozialhilfeträger'}
{daten.aktenzeichen ? ` · Az. ${daten.aktenzeichen}` : ''}
{daten.ueberfaellig > 0 && (
<>
{' · '}
<span className="font-medium text-ampel-rot">
{daten.ueberfaellig} mit abgelaufener Frist
</span>
</>
)}
</>
}
aktion={
<Button asChild>
<Link href={ziel}>Unterlagen bearbeiten</Link>
</Button>
}
>
<Fortschritt
wert={daten.erledigt}
gesamt={daten.gesamt}
beschriftung="Unterlagen geliefert"
/>
{daten.offen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Alle angeforderten Unterlagen sind übersandt. Jetzt liegt es beim Träger – der Vorgang
gehört auf „wartet auf Dritte“.
</p>
) : (
<ul className="divide-y">
{daten.offen.map((zeile) => (
<li key={zeile.id} className="flex items-start justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="font-medium">{zeile.bezeichnung}</p>
<p className="text-sm text-muted-foreground">
{STATUS_BESCHRIFTUNG[zeile.status]}
{zeile.fristBis
? ` · Frist ${formatDatum(zeile.fristBis)}${
zeile.tageBisFrist !== null ? ` (${fristLage(zeile.tageBisFrist)})` : ''
}`
: ' · keine Frist gesetzt'}
</p>
</div>
{zeile.tageBisFrist !== null && zeile.tageBisFrist < 0 && (
<Badge variant="rot" className="shrink-0">
überfällig
</Badge>
)}
</li>
))}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,91 @@
import Link from 'next/link'
import { Inbox } from 'lucide-react'
import type { DokumentArt } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg, KachelZeile } from '@/components/dashboard/kachel'
import type { PosteingangDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* Ungepruefter Posteingang.
*
* Ein Bescheid, der ungesichtet liegen bleibt, kostet die Widerspruchsfrist
* (§ 84 SGG: in der Regel ein Monat ab Bekanntgabe). Deshalb steht hier
* nicht "Dokumente", sondern das, was noch niemand angesehen hat.
*/
const ART_BESCHRIFTUNG: Partial<Record<DokumentArt, string>> = {
BESCHEID: 'Bescheid',
ANTRAG: 'Antrag',
SCHREIBEN: 'Schreiben',
RECHNUNG: 'Rechnung',
KONTOAUSZUG: 'Kontoauszug',
VERTRAG: 'Vertrag',
BESCHLUSS: 'Beschluss',
URKUNDE: 'Urkunde',
GUTACHTEN: 'Gutachten',
AUSWEIS: 'Ausweis',
PROTOKOLL: 'Protokoll',
QUITTUNG: 'Quittung',
MAHNUNG: 'Mahnung',
VOLLMACHT: 'Vollmacht',
ATTEST: 'Attest',
GRUNDBUCHAUSZUG: 'Grundbuchauszug',
FOTO: 'Foto',
SONSTIGES: 'Sonstiges',
}
export function PosteingangKachel({ daten }: { daten: PosteingangDaten | null }) {
if (!daten) return null
return (
<Kachel
titel="Ungeprüfter Posteingang"
icon={Inbox}
zaehler={daten.anzahl}
hinweis={
daten.anzahl > 0
? 'Bei Bescheiden zählt der Zugang, nicht das Datum auf dem Brief – bitte zuerst sichten.'
: undefined
}
aktion={<KachelWeg href="/dokumente?posteingang=NEU">Posteingang durchgehen</KachelWeg>}
>
{daten.anzahl === 0 ? (
<Leerzustand
icon={Inbox}
titel="Kein ungeprüfter Posteingang."
beschreibung="Neu eingescannte oder fotografierte Dokumente landen hier, bis sie gesichtet und zugeordnet sind."
className="py-8"
aktion={
<Button asChild variant="outline">
<Link href="/dokumente/neu?quelle=kamera">Beleg fotografieren</Link>
</Button>
}
/>
) : (
<ul className="divide-y">
{daten.neueste.map((dokument) => (
<li key={dokument.id}>
<KachelZeile href={`/dokumente/${dokument.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">{dokument.titel}</span>
<span className="block text-sm text-muted-foreground">
{ART_BESCHRIFTUNG[dokument.art] ?? dokument.art}
{dokument.absender ? ` · ${dokument.absender}` : ''}
{dokument.eingangAm ? ` · Eingang ${formatDatum(dokument.eingangAm)}` : ''}
</span>
</span>
</KachelZeile>
</li>
))}
{daten.anzahl > daten.neueste.length && (
<li className="pt-2.5 text-sm text-muted-foreground">
und {daten.anzahl - daten.neueste.length} weitere
</li>
)}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,90 @@
import Link from 'next/link'
import { Hourglass } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg } from '@/components/dashboard/kachel'
import { WARTEZEIT_DRINGEND_TAGE } from '@/lib/dashboard/bewertung'
import type { WartetAufAndereDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* "Wartet auf andere" - Vorgaenge, bei denen ich nichts tun kann ausser
* nachzufassen.
*
* Behoerden antworten nicht von allein. Ab drei Wochen ohne Rueckmeldung
* ist eine Sachstandsanfrage faellig; genau die bietet die Zeile an.
*/
export function WartetAufAndere({ daten }: { daten: WartetAufAndereDaten | null }) {
if (!daten) return null
return (
<Kachel
titel="Wartet auf andere"
icon={Hourglass}
zaehler={daten.gesamt}
dringend={daten.dringende > 0}
hinweis={
daten.dringende > 0
? `${daten.dringende} ${
daten.dringende === 1 ? 'Vorgang liegt' : 'Vorgänge liegen'
} seit über ${WARTEZEIT_DRINGEND_TAGE} Tagen ohne Rückmeldung.`
: undefined
}
aktion={<KachelWeg href="/vorgaenge?status=WARTET_AUF_DRITTE">Alle wartenden Vorgänge</KachelWeg>}
>
{daten.eintraege.length === 0 ? (
<Leerzustand
icon={Hourglass}
titel="Zurzeit wartet nichts auf Dritte."
beschreibung="Sobald ein Vorgang auf den Status „wartet auf Dritte“ gesetzt wird, erscheint er hier mit den Tagen seit dem letzten Kontakt."
className="py-8"
/>
) : (
<ul className="divide-y">
{daten.eintraege.map((eintrag) => (
<li key={eintrag.id} className="py-3 first:pt-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Link
href={`/vorgaenge/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
<p className="text-sm text-muted-foreground">
{eintrag.gegenstelle ?? 'Gegenstelle nicht vermerkt'}
{eintrag.aktenzeichen ? ` · Az. ${eintrag.aktenzeichen}` : ''}
</p>
<p className="text-sm text-muted-foreground">
{eintrag.letzterKontaktAm
? `Letzter Kontakt am ${formatDatum(eintrag.letzterKontaktAm)}`
: 'Kein Kontakt vermerkt – gerechnet ab dem Anlegen'}
</p>
{eintrag.naechsterSchritt && (
<p className="mt-1 text-sm">
<span className="font-medium">Nächster Schritt:</span>{' '}
{eintrag.naechsterSchritt}
</p>
)}
</div>
<Badge variant={eintrag.dringend ? 'rot' : 'gelb'} className="shrink-0">
{eintrag.tageOhneRueckmeldung}{' '}
{eintrag.tageOhneRueckmeldung === 1 ? 'Tag' : 'Tage'} ohne Antwort
</Badge>
</div>
<div className="mt-2">
<Button asChild size="sm" variant={eintrag.dringend ? 'default' : 'outline'}>
<Link href={`/vorgaenge/${eintrag.id}?aktion=sachstandsanfrage`}>
Sachstandsanfrage erzeugen
</Link>
</Button>
</div>
</li>
))}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Check, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { fristErledigen } from '@/server/actions/fristen'
/**
* Ein Klick, eine Frist erledigt.
*
* Bewusst ohne Rueckfragedialog: Der Handgriff kommt oft unterwegs, mit dem
* Daumen, im Heim. Ein versehentliches Abhaken laesst sich ueber "Bearbeiten"
* wieder zuruecknehmen, und im Audit-Log steht beides.
*/
export function ErledigenKnopf({
betreuteId,
fristId,
titel,
wiederkehrend = false,
groesse = 'sm',
className,
}: {
betreuteId: string
fristId: string
titel: string
wiederkehrend?: boolean
groesse?: 'sm' | 'default'
className?: string
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [fehler, setFehler] = useState<string | null>(null)
function abhaken() {
setFehler(null)
starten(async () => {
const ergebnis = await fristErledigen(betreuteId, fristId)
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
router.refresh()
})
}
return (
<div className={className}>
<Button
type="button"
variant="outline"
size={groesse}
onClick={abhaken}
disabled={laeuft}
aria-label={`Frist „${titel}" als erledigt abhaken`}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Check aria-hidden="true" />
)}
{laeuft ? 'Wird gespeichert…' : wiederkehrend ? 'Erledigt, nächste anlegen' : 'Erledigt'}
</Button>
{fehler && (
<p role="alert" className="mt-1.5 text-sm font-medium text-destructive">
{fehler}
</p>
)}
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Feld } from '@/components/feld'
import { Select } from '@/components/ui/select'
import {
FRIST_STATUS,
FRIST_STATUS_BESCHRIFTUNG,
FRIST_TYPEN,
FRIST_TYP_BESCHRIFTUNG,
} from '@/lib/validierung/fristen'
/**
* Filter fuer die Fristenliste.
*
* Der Zustand steht in der Adresse, nicht im Bauteil: So laesst sich eine
* gefilterte Ansicht als Lesezeichen ablegen ("alle Ausschlussfristen") und
* der Zurueck-Knopf des Browsers tut das Erwartete.
*/
export const ZEITRAEUME = [
{ wert: 'offen', beschriftung: 'Alles Offene' },
{ wert: 'ueberfaellig', beschriftung: 'Nur Überfälliges' },
{ wert: 'woche', beschriftung: 'Nächste 7 Tage' },
{ wert: 'monat', beschriftung: 'Nächste 31 Tage' },
{ wert: 'jahr', beschriftung: 'Nächste 12 Monate' },
{ wert: 'alle', beschriftung: 'Alle, auch Erledigtes' },
] as const
export const KATEGORIEN = [
{ wert: '', beschriftung: 'Alle Kategorien' },
{ wert: 'pflicht', beschriftung: 'Gesetzliche Pflichtfristen' },
{ wert: 'ausschluss', beschriftung: 'Nur Ausschlussfristen' },
{ wert: 'wiederkehrend', beschriftung: 'Nur Wiederkehrende' },
{ wert: 'eigene', beschriftung: 'Selbst angelegte' },
] as const
export function Filterleiste() {
const router = useRouter()
const parameter = useSearchParams()
const [laeuft, starten] = useTransition()
function setzen(name: string, wert: string) {
const neu = new URLSearchParams(parameter.toString())
if (wert === '') neu.delete(name)
else neu.set(name, wert)
const abfrage = neu.toString()
starten(() => router.push(abfrage ? `/fristen?${abfrage}` : '/fristen'))
}
const etwasGesetzt = ['zeitraum', 'kategorie', 'typ', 'status'].some((n) => parameter.get(n))
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="filter-zeitraum" beschriftung="Zeitraum">
<Select
value={parameter.get('zeitraum') ?? 'offen'}
onChange={(e) => setzen('zeitraum', e.target.value)}
disabled={laeuft}
>
{ZEITRAEUME.map((z) => (
<option key={z.wert} value={z.wert}>
{z.beschriftung}
</option>
))}
</Select>
</Feld>
<Feld id="filter-kategorie" beschriftung="Kategorie">
<Select
value={parameter.get('kategorie') ?? ''}
onChange={(e) => setzen('kategorie', e.target.value)}
disabled={laeuft}
>
{KATEGORIEN.map((k) => (
<option key={k.wert} value={k.wert}>
{k.beschriftung}
</option>
))}
</Select>
</Feld>
<Feld id="filter-typ" beschriftung="Art der Frist">
<Select
value={parameter.get('typ') ?? ''}
onChange={(e) => setzen('typ', e.target.value)}
disabled={laeuft}
>
<option value="">Alle Arten</option>
{FRIST_TYPEN.map((t) => (
<option key={t} value={t}>
{FRIST_TYP_BESCHRIFTUNG[t]}
</option>
))}
</Select>
</Feld>
<Feld id="filter-status" beschriftung="Status">
<Select
value={parameter.get('status') ?? ''}
onChange={(e) => setzen('status', e.target.value)}
disabled={laeuft}
>
<option value="">Alle Status</option>
{FRIST_STATUS.map((s) => (
<option key={s} value={s}>
{FRIST_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
</div>
{etwasGesetzt && (
<div className="mt-4">
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={() => starten(() => router.push('/fristen'))}
>
<RotateCcw aria-hidden="true" />
Filter zurücksetzen
</Button>
</div>
)}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { CalendarClock, Loader2, Trash2, XCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
fristEntfallenLassen,
fristInPapierkorb,
fristVerschieben,
} from '@/server/actions/fristen'
/**
* Die drei Wege, eine Frist loszuwerden, ohne sie zu erledigen.
*
* Sie sind bewusst nicht gleich sichtbar wie "Erledigt": Verschieben,
* Entfallen und Papierkorb sind Ausnahmen, und jede hinterlaesst eine Spur
* im Audit-Log.
*/
export function FristAktionen({
betreuteId,
fristId,
datum,
ausschlussfrist,
}: {
betreuteId: string
fristId: string
/** ISO-Tagesdatum, "2026-09-14". */
datum: string
ausschlussfrist: boolean
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [offen, setOffen] = useState<'verschieben' | 'entfallen' | null>(null)
const [neuesDatum, setNeuesDatum] = useState(datum)
const [grund, setGrund] = useState('')
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null)
function melden(ergebnis: { ok: boolean; hinweis?: string; fehler?: string }) {
if (ergebnis.ok) {
setMeldung({ art: 'ok', text: ergebnis.hinweis ?? 'Gespeichert.' })
setOffen(null)
setGrund('')
router.refresh()
} else {
setMeldung({ art: 'fehler', text: ergebnis.fehler ?? 'Das hat nicht geklappt.' })
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{!ausschlussfrist && (
<Button
type="button"
variant="outline"
size="sm"
disabled={laeuft}
onClick={() => setOffen(offen === 'verschieben' ? null : 'verschieben')}
>
<CalendarClock aria-hidden="true" />
Verschieben
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={laeuft}
onClick={() => setOffen(offen === 'entfallen' ? null : 'entfallen')}
>
<XCircle aria-hidden="true" />
Entfallen lassen
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={() =>
starten(async () => {
const ergebnis = await fristInPapierkorb(betreuteId, fristId)
if (ergebnis.ok) router.push('/fristen')
else melden(ergebnis)
})
}
>
<Trash2 aria-hidden="true" />
In den Papierkorb
</Button>
</div>
{ausschlussfrist && (
<p className="text-sm text-muted-foreground">
Ausschlussfristen lassen sich nicht verschieben — das Verfallsdatum steht im Gesetz
und ändert sich nicht dadurch, dass man es im Kalender umträgt.
</p>
)}
{offen === 'verschieben' && (
<div className="space-y-3 rounded-lg border bg-muted/40 p-4">
<Feld
id="verschieben-datum"
beschriftung="Neuer Stichtag"
erklaerung="Die alte Frist bleibt als „verschoben“ in der Akte stehen."
>
<Input
type="date"
value={neuesDatum}
onChange={(e) => setNeuesDatum(e.target.value)}
/>
</Feld>
<Feld id="verschieben-grund" beschriftung="Grund (empfohlen)">
<Textarea rows={2} value={grund} onChange={(e) => setGrund(e.target.value)} />
</Feld>
<Button
type="button"
disabled={laeuft}
onClick={() =>
starten(async () => melden(await fristVerschieben(betreuteId, fristId, neuesDatum, grund)))
}
>
{laeuft && <Loader2 className="animate-spin" aria-hidden="true" />}
Verschieben
</Button>
</div>
)}
{offen === 'entfallen' && (
<div className="space-y-3 rounded-lg border bg-muted/40 p-4">
<Feld
id="entfallen-grund"
beschriftung="Warum ist die Frist gegenstandslos?"
erklaerung="Zum Beispiel: Antrag zurückgenommen, Konto aufgelöst, Vorgang erledigt sich anders."
>
<Textarea rows={2} value={grund} onChange={(e) => setGrund(e.target.value)} />
</Feld>
<Button
type="button"
variant="secondary"
disabled={laeuft}
onClick={() =>
starten(async () => melden(await fristEntfallenLassen(betreuteId, fristId, grund)))
}
>
{laeuft && <Loader2 className="animate-spin" aria-hidden="true" />}
Als entfallen vermerken
</Button>
</div>
)}
{meldung && (
<p
role="status"
className={
meldung.art === 'fehler'
? 'text-sm font-medium text-destructive'
: 'text-sm text-muted-foreground'
}
>
{meldung.text}
</p>
)}
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Feld, Feldgruppe } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { FristAnzeige } from '@/components/fristen/typen'
import { WIEDERHOLUNGEN } from '@/lib/fristen/wiederholung'
import {
FRIST_TYPEN,
FRIST_TYP_BESCHRIFTUNG,
FRIST_TYP_ERKLAERUNG,
VORWARNUNG_STUFEN,
type FristZustand,
} from '@/lib/validierung/fristen'
/**
* Formular zum Anlegen und Aendern einer Frist.
*
* Die Pflichtangaben stehen oben und sind wenige: Titel und Datum. Alles
* andere ist Feinschliff und darf leer bleiben - eine Frist, die man nicht
* anlegt, weil das Formular zu lang war, hilft niemandem.
*/
export function FristFormular({
betreuteId,
aktion,
frist,
vorgaenge,
}: {
betreuteId: string
aktion: (vorher: FristZustand, formular: FormData) => Promise<FristZustand>
frist?: FristAnzeige
vorgaenge: { id: string; titel: string }[]
}) {
const [zustand, absenden] = useActionState<FristZustand, FormData>(aktion, {})
const werte = zustand.werte ?? {}
const wert = (name: string, standard: string) => {
const w = werte[name]
if (Array.isArray(w)) return w[0] ?? standard
return w ?? standard
}
const vorwarnungVorbelegt: string[] = Array.isArray(werte.vorwarnungTage)
? werte.vorwarnungTage
: (frist?.vorwarnungTage ?? [60, 30, 7, 1]).map(String)
const bekannteRegel = WIEDERHOLUNGEN.some(
(w) => w.wert === (frist?.wiederholungRrule ?? ''),
)
const [eigeneRegelSichtbar, setEigeneRegelSichtbar] = useState(
Boolean(frist?.wiederholungRrule) && !bekannteRegel,
)
return (
<form action={absenden} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{frist && <input type="hidden" name="fristId" value={frist.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feldgruppe
titel="Was ist zu tun?"
beschreibung="Schreib den Titel so, dass du in einem Jahr noch weißt, was gemeint war."
>
<Feld
id="titel"
beschriftung="Titel"
pflicht
fehler={zustand.felder?.titel}
className="sm:col-span-2"
>
<Input
name="titel"
defaultValue={wert('titel', frist?.titel ?? '')}
maxLength={200}
required
autoComplete="off"
/>
</Feld>
<Feld
id="datum"
beschriftung="Stichtag"
pflicht
fehler={zustand.felder?.datum}
erklaerung="Der Tag, an dem es erledigt sein muss — nicht der Tag, an dem du anfangen willst."
>
<Input type="date" name="datum" defaultValue={wert('datum', frist?.datum ?? '')} required />
</Feld>
<Feld
id="typ"
beschriftung="Art der Frist"
fehler={zustand.felder?.typ}
erklaerung={FRIST_TYP_ERKLAERUNG[frist?.typ ?? 'SELBST_GESETZT']}
>
<Select name="typ" defaultValue={wert('typ', frist?.typ ?? 'SELBST_GESETZT')}>
{FRIST_TYPEN.map((t) => (
<option key={t} value={t}>
{FRIST_TYP_BESCHRIFTUNG[t]}
</option>
))}
</Select>
</Feld>
<Feld
id="beschreibung"
beschriftung="Was genau ist zu tun?"
fehler={zustand.felder?.beschreibung}
erklaerung="Der nächste konkrete Handgriff, benötigte Unterlagen, Ansprechpartner."
className="sm:col-span-2"
>
<Textarea
name="beschreibung"
rows={5}
maxLength={4000}
defaultValue={wert('beschreibung', frist?.beschreibung ?? '')}
/>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Einordnung"
beschreibung="Woraus ergibt sich die Frist, und wozu gehört sie?"
>
<Feld
id="rechtsgrundlage"
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
erklaerung="Freitext, z. B. „§ 1863 Abs. 3 BGB“ oder „Bescheid vom 12.03.2026“."
>
<Input
name="rechtsgrundlage"
maxLength={120}
defaultValue={wert('rechtsgrundlage', frist?.rechtsgrundlage ?? '')}
autoComplete="off"
/>
</Feld>
<Feld
id="vorgangId"
beschriftung="Gehört zu einem Vorgang"
fehler={zustand.felder?.vorgangId}
erklaerung="Optional. Die Frist taucht dann auch in der Fallakte auf."
>
<Select name="vorgangId" defaultValue={wert('vorgangId', frist?.vorgang?.id ?? '')}>
<option value="">Keinem Vorgang zuordnen</option>
{vorgaenge.map((v) => (
<option key={v.id} value={v.id}>
{v.titel}
</option>
))}
</Select>
</Feld>
<div className="sm:col-span-2">
<label className="flex items-start gap-3 rounded-md border border-ampel-rot/30 bg-ampel-rot/5 p-3">
<Checkbox
name="ausschlussfrist"
defaultChecked={
werte.ausschlussfrist ? werte.ausschlussfrist === 'on' : (frist?.ausschlussfrist ?? false)
}
className="mt-0.5"
/>
<span className="text-sm">
<span className="font-medium">Das ist eine Ausschlussfrist.</span>{' '}
<span className="text-muted-foreground">
Läuft sie ab, ist der Anspruch ersatzlos weg — es gibt keine Nachsicht und
keine Wiedereinsetzung. Solche Fristen werden deutlich rot markiert und früher
vorgewarnt. Beispiel: die Aufwandspauschale nach § 1878 BGB.
</span>
</span>
</label>
</div>
</Feldgruppe>
<Feldgruppe
titel="Erinnerung und Wiederholung"
beschreibung="Wann soll die Frist vorher auffallen, und kommt sie regelmäßig wieder?"
>
<fieldset className="sm:col-span-2">
<legend className="text-sm font-medium">Vorwarnung</legend>
<p className="mt-0.5 text-sm text-muted-foreground">
An diesen Tagen vor dem Stichtag rückt die Frist im Dashboard nach oben und
erscheint als Wecker im abonnierten Kalender.
</p>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">
{VORWARNUNG_STUFEN.map((stufe) => (
<label key={stufe} className="flex items-center gap-2 py-1.5 text-sm">
<Checkbox
name="vorwarnungTage"
value={String(stufe)}
defaultChecked={vorwarnungVorbelegt.includes(String(stufe))}
/>
{stufe === 1 ? '1 Tag' : `${stufe} Tage`}
</label>
))}
</div>
{zustand.felder?.vorwarnungTage && (
<p className="mt-1.5 text-sm font-medium text-destructive">
{zustand.felder.vorwarnungTage}
</p>
)}
</fieldset>
<Feld
id="wiederholungRrule"
beschriftung="Wiederholung"
fehler={zustand.felder?.wiederholungRrule}
erklaerung="Beim Abhaken legt die Anwendung den nächsten Termin automatisch an."
>
<Select
name="wiederholungRrule"
defaultValue={bekannteRegel ? (frist?.wiederholungRrule ?? '') : ''}
onChange={(e) => {
if (e.target.value !== '') setEigeneRegelSichtbar(false)
}}
>
{WIEDERHOLUNGEN.map((w) => (
<option key={w.wert} value={w.wert}>
{w.beschriftung}
</option>
))}
</Select>
</Feld>
<div className="space-y-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEigeneRegelSichtbar((s) => !s)}
>
{eigeneRegelSichtbar ? 'Eigene Regel verwerfen' : 'Eigene Regel eingeben'}
</Button>
{eigeneRegelSichtbar && (
<Feld
id="wiederholungEigene"
beschriftung="Eigene Wiederholungsregel"
erklaerung="iCalendar-Schreibweise, z. B. FREQ=MONTHLY;INTERVAL=4. Sie hat Vorrang vor der Auswahl."
>
<Input
name="wiederholungEigene"
defaultValue={bekannteRegel ? '' : (frist?.wiederholungRrule ?? '')}
autoComplete="off"
spellCheck={false}
/>
</Feld>
)}
</div>
</Feldgruppe>
<Rechtshinweis rechtsgrundlage="§ 1863 BGB, § 1878 BGB" />
<div className="flex flex-wrap gap-3">
<Absendeknopf neu={!frist} />
<Button type="button" variant="outline" asChild>
<Link href={frist ? `/fristen/${frist.id}` : '/fristen'}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
function Absendeknopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert…' : neu ? 'Frist anlegen' : 'Änderungen speichern'}
</Button>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Fristkarte } from '@/components/fristen/fristkarte'
import type { FristAnzeige } from '@/components/fristen/typen'
import {
AMPEL_ERKLAERUNG,
AMPEL_REIHENFOLGE,
AMPEL_UEBERSCHRIFT,
fristAmpel,
type Ampelstufe,
} from '@/lib/fristen/ampel'
import { ausIsoTag } from '@/lib/fristen/datum'
/**
* Fristen, gruppiert nach Ampelstufe.
*
* Die Reihenfolge ist nicht verhandelbar: Ueberfaellig steht oben. Wer die
* Seite oeffnet, soll ohne Scrollen sehen, was schiefliegt - alles andere
* kann warten.
*/
export function Fristenliste({
fristen,
darfSchreiben,
}: {
fristen: FristAnzeige[]
darfSchreiben: boolean
}) {
const gruppen = new Map<Ampelstufe, FristAnzeige[]>()
for (const stufe of AMPEL_REIHENFOLGE) gruppen.set(stufe, [])
for (const frist of fristen) {
const datum = ausIsoTag(frist.datum)
if (!datum) continue
gruppen.get(fristAmpel(datum, frist.status).stufe)!.push(frist)
}
// Innerhalb einer Gruppe nach Datum: bei den ueberfaelligen zuerst die
// aeltesten, sonst die naechstliegenden.
for (const [stufe, liste] of gruppen) {
liste.sort((a, b) => a.datum.localeCompare(b.datum))
if (stufe === 'erledigt') liste.reverse()
}
return (
<div className="space-y-8">
{AMPEL_REIHENFOLGE.map((stufe) => {
const liste = gruppen.get(stufe)!
if (liste.length === 0) return null
return (
<section key={stufe} aria-labelledby={`gruppe-${stufe}`}>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 id={`gruppe-${stufe}`} className="text-lg font-semibold tracking-tight">
{AMPEL_UEBERSCHRIFT[stufe]}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{liste.length}
</span>
</h2>
<p className="text-sm text-muted-foreground">{AMPEL_ERKLAERUNG[stufe]}</p>
</div>
<ul className="space-y-3">
{liste.map((frist) => (
<li key={frist.id}>
<Fristkarte frist={frist} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
</section>
)
})}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import Link from 'next/link'
import { AlertTriangle, CalendarClock, FolderKanban, Repeat, Scale } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ErledigenKnopf } from '@/components/fristen/erledigen-knopf'
import type { FristAnzeige } from '@/components/fristen/typen'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import { ausIsoTag, nichtWerktagHinweis, wochentagName } from '@/lib/fristen/datum'
import { rruleBeschreiben } from '@/lib/fristen/wiederholung'
import { FRIST_TYP_BESCHRIFTUNG } from '@/lib/validierung/fristen'
import { formatDatum } from '@/lib/utils'
/**
* Eine Frist als Karte.
*
* Reihenfolge der Information ist die Reihenfolge der Fragen, die sich im
* Alltag stellen: Wie dringend? Was ist zu tun? Bis wann? Und erst dann,
* woraus sich das ergibt.
*/
export function Fristkarte({
frist,
darfSchreiben,
}: {
frist: FristAnzeige
darfSchreiben: boolean
}) {
const datum = ausIsoTag(frist.datum)
if (!datum) return null
const ampel = fristAmpel(datum, frist.status)
const text = ampelText(ampel, frist.ausschlussfrist)
const werktagshinweis = frist.status === 'OFFEN' ? nichtWerktagHinweis(datum) : null
const abgeschlossen = ampel.stufe === 'erledigt'
return (
<article
className={[
'rounded-lg border bg-card p-4 shadow-sm transition-colors',
abgeschlossen ? 'opacity-70' : '',
frist.ausschlussfrist && !abgeschlossen ? 'border-ampel-rot/50' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={ampel.variante}>
{ampel.stufe === 'ueberfaellig' && <AlertTriangle className="size-3" aria-hidden="true" />}
{text}
</Badge>
{frist.ausschlussfrist && !abgeschlossen && (
<Badge variant="rot">Ausschlussfrist — Anspruch verfällt</Badge>
)}
<Badge variant="outline">{FRIST_TYP_BESCHRIFTUNG[frist.typ]}</Badge>
{frist.wiederholungRrule && (
<Badge variant="secondary">
<Repeat className="size-3" aria-hidden="true" />
{rruleBeschreiben(frist.wiederholungRrule)}
</Badge>
)}
</div>
<h3 className="mt-2.5 text-base font-semibold leading-snug">
<Link href={`/fristen/${frist.id}`} className="hover:underline">
{frist.titel}
</Link>
</h3>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<CalendarClock className="size-4 shrink-0" aria-hidden="true" />
<span>
{wochentagName(datum)}, {formatDatum(datum)}
</span>
{frist.rechtsgrundlage && (
<span className="inline-flex items-center gap-1">
<Scale className="size-3.5 shrink-0" aria-hidden="true" />
{frist.rechtsgrundlage}
</span>
)}
{frist.vorgang && (
<Link
href={`/vorgaenge/${frist.vorgang.id}`}
className="inline-flex items-center gap-1 hover:text-foreground hover:underline"
>
<FolderKanban className="size-3.5 shrink-0" aria-hidden="true" />
{frist.vorgang.titel}
</Link>
)}
</p>
{werktagshinweis && (
<p className="mt-2 text-sm text-ampel-orange">{werktagshinweis}</p>
)}
{frist.status === 'ERLEDIGT' && frist.erledigtAm && (
<p className="mt-2 text-sm text-muted-foreground">
Erledigt am {formatDatum(ausIsoTag(frist.erledigtAm))}.
</p>
)}
{darfSchreiben && frist.status === 'OFFEN' && (
<div className="mt-3 flex flex-wrap gap-2">
<ErledigenKnopf
betreuteId={frist.betreuteId}
fristId={frist.id}
titel={frist.titel}
wiederkehrend={Boolean(frist.wiederholungRrule)}
/>
</div>
)}
</article>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
/**
* Die Adresse des abonnierbaren Kalenders zum Kopieren.
*
* Die Adresse ist der Schluessel - wer sie hat, sieht die Fristen. Deshalb
* steht der Warnhinweis direkt daneben und nicht in einer Hilfeseite.
*/
export function KalenderAbo({ feedUrl }: { feedUrl: string }) {
const [kopiert, setKopiert] = useState(false)
async function kopieren() {
try {
await navigator.clipboard.writeText(feedUrl)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 2500)
} catch {
// Ohne Zwischenablage-Recht bleibt das Feld zum Markieren von Hand -
// deshalb hier kein Fehler, sondern einfach nichts.
setKopiert(false)
}
}
return (
<div className="space-y-3 rounded-lg border bg-card p-4">
<div>
<h2 className="text-base font-semibold">Fristen im eigenen Kalender abonnieren</h2>
<p className="mt-1 text-sm text-muted-foreground">
Diese Adresse in Google Kalender, Apple Kalender oder Outlook als Abonnement
eintragen (nicht als Import — sonst aktualisiert sich nichts). Die Vorwarnstufen
kommen als Erinnerungen mit.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Feld
id="kalender-feed"
beschriftung="Kalenderadresse"
className="min-w-0 flex-1"
erklaerung="Wer diese Adresse kennt, sieht die Termine — behandle sie wie ein Passwort."
>
<Input readOnly value={feedUrl} onFocus={(e) => e.currentTarget.select()} spellCheck={false} />
</Feld>
<Button type="button" variant="outline" onClick={kopieren} className="shrink-0">
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Der Kalender enthält bewusst nur Titel und Stichtag — keine Diagnosen, keine
Kontostände, keinen Namen der betreuten Person.
</p>
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
import Link from 'next/link'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import type { FristAnzeige } from '@/components/fristen/typen'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import {
alsIsoTag,
ausIsoTag,
feiertagName,
heuteAlsTag,
istWochenende,
plusTage,
tag,
} from '@/lib/fristen/datum'
import { formatDatum } from '@/lib/utils'
/**
* Monatsansicht am Bildschirm, Agenda am Handy.
*
* Ein Gitter mit 42 winzigen Zellen ist auf einem Telefon unbrauchbar -
* die Zellen waeren kleiner als eine Fingerkuppe. Deshalb steht dort
* dieselbe Information als Liste: Tag fuer Tag, nur die Tage mit Fristen.
*/
const MONATSNAMEN = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
] as const
const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const
export function monatsname(monat: number): string {
return MONATSNAMEN[monat - 1] ?? ''
}
export function KalenderMonat({
jahr,
monat,
fristen,
}: {
jahr: number
monat: number
fristen: FristAnzeige[]
}) {
const heute = heuteAlsTag()
const ersterTag = tag(jahr, monat, 1)
// getUTCDay liefert 0 fuer Sonntag; in Deutschland beginnt die Woche am
// Montag, deshalb die Verschiebung.
const versatz = (ersterTag.getUTCDay() + 6) % 7
const rasterStart = plusTage(ersterTag, -versatz)
const nachTag = new Map<string, FristAnzeige[]>()
for (const frist of fristen) {
const liste = nachTag.get(frist.datum) ?? []
liste.push(frist)
nachTag.set(frist.datum, liste)
}
const vorigerMonat = monat === 1 ? { jahr: jahr - 1, monat: 12 } : { jahr, monat: monat - 1 }
const naechsterMonat = monat === 12 ? { jahr: jahr + 1, monat: 1 } : { jahr, monat: monat + 1 }
const zellen = Array.from({ length: 42 }, (_, i) => plusTage(rasterStart, i))
// Sechste Zeile nur zeigen, wenn sie noch Tage dieses Monats enthaelt.
const zeilen = zellen[35]!.getUTCMonth() === monat - 1 ? 6 : 5
const tageMitFristen = [...nachTag.entries()]
.filter(([schluessel]) => schluessel.startsWith(`${jahr}-${String(monat).padStart(2, '0')}`))
.sort((a, b) => a[0].localeCompare(b[0]))
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="icon" asChild>
<Link
href={`/fristen/kalender?jahr=${vorigerMonat.jahr}&monat=${vorigerMonat.monat}`}
aria-label={`Zum ${monatsname(vorigerMonat.monat)} ${vorigerMonat.jahr}`}
>
<ChevronLeft aria-hidden="true" />
</Link>
</Button>
<h2 className="text-lg font-semibold tracking-tight" aria-live="polite">
{monatsname(monat)} {jahr}
</h2>
<Button variant="outline" size="icon" asChild>
<Link
href={`/fristen/kalender?jahr=${naechsterMonat.jahr}&monat=${naechsterMonat.monat}`}
aria-label={`Zum ${monatsname(naechsterMonat.monat)} ${naechsterMonat.jahr}`}
>
<ChevronRight aria-hidden="true" />
</Link>
</Button>
</div>
{/* ---------------- Monatsgitter, ab Tablet ---------------- */}
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full min-w-[42rem] table-fixed border-collapse">
<caption className="sr-only">
Fristen im {monatsname(monat)} {jahr}
</caption>
<thead>
<tr>
{WOCHENTAGE_KURZ.map((name) => (
<th
key={name}
scope="col"
className="border-b bg-muted/50 p-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{name}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: zeilen }, (_, woche) => (
<tr key={woche}>
{Array.from({ length: 7 }, (_, wochentag) => {
const datum = zellen[woche * 7 + wochentag]!
const schluessel = alsIsoTag(datum)
const imMonat = datum.getUTCMonth() === monat - 1
const istHeute = schluessel === alsIsoTag(heute)
const feiertag = feiertagName(datum)
const eintraege = nachTag.get(schluessel) ?? []
return (
<td
key={wochentag}
className={[
'h-28 border border-border/60 align-top p-1.5',
imMonat ? '' : 'bg-muted/30 text-muted-foreground',
istWochenende(datum) || feiertag ? 'bg-muted/40' : '',
istHeute ? 'ring-2 ring-inset ring-primary' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="flex items-baseline justify-between gap-1">
<span
className={['text-sm', istHeute ? 'font-bold text-primary' : '']
.filter(Boolean)
.join(' ')}
>
{datum.getUTCDate()}
{istHeute && <span className="sr-only"> (heute)</span>}
</span>
{feiertag && (
<span
className="truncate text-[0.65rem] text-muted-foreground"
title={feiertag}
>
{feiertag}
</span>
)}
</div>
<ul className="mt-1 space-y-1">
{eintraege.map((frist) => {
const fristDatum = ausIsoTag(frist.datum)!
const ampel = fristAmpel(fristDatum, frist.status)
return (
<li key={frist.id}>
<Link
href={`/fristen/${frist.id}`}
className={[
'block truncate rounded px-1.5 py-1 text-xs hover:underline',
ampel.variante === 'rot'
? 'bg-ampel-rot/15 text-ampel-rot'
: ampel.variante === 'orange'
? 'bg-ampel-orange/15 text-ampel-orange'
: ampel.variante === 'gelb'
? 'bg-ampel-gelb/15 text-ampel-gelb'
: ampel.variante === 'gruen'
? 'bg-ampel-gruen/15 text-ampel-gruen'
: 'bg-secondary text-secondary-foreground',
].join(' ')}
title={`${frist.titel} — ${ampelText(ampel, frist.ausschlussfrist)}`}
>
{frist.ausschlussfrist && (
<span aria-hidden="true">! </span>
)}
{frist.titel}
</Link>
</li>
)
})}
</ul>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
{/* ---------------- Agenda am Handy ---------------- */}
<div className="sm:hidden">
{tageMitFristen.length === 0 ? (
<Leerzustand
titel={`Im ${monatsname(monat)} ${jahr} steht nichts an`}
beschreibung="Blättere zum nächsten Monat oder lege eine Frist an."
/>
) : (
<ol className="space-y-4">
{tageMitFristen.map(([schluessel, eintraege]) => {
const datum = ausIsoTag(schluessel)!
const feiertag = feiertagName(datum)
return (
<li key={schluessel}>
<h3 className="mb-2 text-sm font-semibold">
{formatDatum(datum)}
{feiertag && (
<span className="ml-2 font-normal text-muted-foreground">{feiertag}</span>
)}
</h3>
<ul className="space-y-2">
{eintraege.map((frist) => {
const ampel = fristAmpel(ausIsoTag(frist.datum)!, frist.status)
return (
<li key={frist.id}>
<Link
href={`/fristen/${frist.id}`}
className="block rounded-lg border bg-card p-3 hover:bg-accent"
>
<Badge variant={ampel.variante}>
{ampelText(ampel, frist.ausschlussfrist)}
</Badge>
<span className="mt-1.5 block text-sm font-medium">{frist.titel}</span>
</Link>
</li>
)
})}
</ul>
</li>
)
})}
</ol>
)}
</div>
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, Scale } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { pflichtfristenNeuAbleiten } from '@/server/actions/fristen'
/**
* Leitet die gesetzlichen Pflichtfristen aus dem Bestellungsdatum ab.
*
* Der Knopf darf beliebig oft gedrueckt werden - die Ableitung legt nichts
* doppelt an und macht nichts wieder auf, was schon erledigt ist. Deshalb
* auch keine Sicherheitsabfrage.
*/
export function PflichtfristenKnopf({
betreuteId,
variante = 'outline',
}: {
betreuteId: string
variante?: 'default' | 'outline'
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null)
function ableiten() {
setMeldung(null)
starten(async () => {
const ergebnis = await pflichtfristenNeuAbleiten(betreuteId)
if (!ergebnis.ok) {
setMeldung({ art: 'fehler', text: ergebnis.fehler })
return
}
setMeldung({ art: 'ok', text: ergebnis.hinweis ?? 'Fertig.' })
router.refresh()
})
}
return (
<div className="space-y-1.5">
<Button type="button" variant={variante} onClick={ableiten} disabled={laeuft}>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Scale aria-hidden="true" />
)}
{laeuft ? 'Wird berechnet…' : 'Pflichtfristen ableiten'}
</Button>
{meldung && (
<p
role="status"
className={
meldung.art === 'fehler'
? 'text-sm font-medium text-destructive'
: 'text-sm text-muted-foreground'
}
>
{meldung.text}
</p>
)}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import type { Frist } from '@prisma/client'
import { alsIsoTag } from '@/lib/fristen/datum'
import type { FristStatusWert, FristTypWert } from '@/lib/validierung/fristen'
/**
* Die Form, in der eine Frist durch die Oberflaeche wandert.
*
* Bewusst mit `datum` als "2026-09-14" statt als Date: Diese Objekte gehen
* von Server Components an Client Components, und ein Datum ohne Uhrzeit
* ueberlebt den Weg als Zeichenkette unbeschadet - ein Date wuerde in der
* Zeitzone des Browsers wieder interpretiert und koennte einen Tag
* springen. Genau dieser Fehler kostet im Fristenwesen einen Tag zu viel.
*/
export type FristAnzeige = {
id: string
betreuteId: string
titel: string
beschreibung: string | null
/** ISO-Tagesdatum, "2026-09-14". */
datum: string
typ: FristTypWert
status: FristStatusWert
rechtsgrundlage: string | null
vorwarnungTage: number[]
wiederholungRrule: string | null
ausschlussfrist: boolean
systemSchluessel: string | null
/** ISO-Tagesdatum oder null. */
erledigtAm: string | null
vorgang: { id: string; titel: string } | null
hatVorgaenger: boolean
}
type FristMitVorgang = Frist & { vorgang?: { id: string; titel: string } | null }
/** Wandelt einen Datensatz aus der Datenbank in die Anzeigeform. */
export function alsAnzeige(frist: FristMitVorgang): FristAnzeige {
return {
id: frist.id,
betreuteId: frist.betreuteId,
titel: frist.titel,
beschreibung: frist.beschreibung,
datum: alsIsoTag(frist.datum),
typ: frist.typ,
status: frist.status,
rechtsgrundlage: frist.rechtsgrundlage,
vorwarnungTage: frist.vorwarnungTage,
wiederholungRrule: frist.wiederholungRrule,
ausschlussfrist: frist.ausschlussfrist,
systemSchluessel: frist.systemSchluessel,
erledigtAm: frist.erledigtAm ? alsIsoTag(frist.erledigtAm) : null,
vorgang: frist.vorgang ?? null,
hatVorgaenger: Boolean(frist.vorgaengerFristId),
}
}
@@ -0,0 +1,157 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte'
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/**
* Formular fuer einen Kontakt - Behoerde, Heim, Arzt, Notar.
*
* Das Aktenzeichen steht bewusst gleich neben dem Namen: Am Telefon ist
* es das Erste, wonach gefragt wird.
*/
export type KontaktWerte = {
id: string
name: string
organisation: string | null
rolle: string
telefon: string | null
mobil: string | null
fax: string | null
email: string | null
strasse: string | null
plz: string | null
ort: string | null
aktenzeichen: string | null
notizen: string | null
}
const ANFANG: KontaktZustand = {}
export function KontaktFormular({
betreuteId,
kontakt,
}: {
betreuteId: string
kontakt?: KontaktWerte
}) {
const [zustand, aktion] = useActionState(kontaktSpeichern, ANFANG)
const wert = (feld: keyof KontaktWerte): string =>
zustand.werte?.[feld] ?? (kontakt ? (kontakt[feld] ?? '') : '')
return (
<form action={aktion} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{kontakt && <input type="hidden" name="id" value={kontakt.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feldgruppe titel="Wer ist das?">
<Feld id="name" beschriftung="Name" pflicht fehler={zustand.felder?.name}>
<Input name="name" defaultValue={wert('name')} autoComplete="name" required />
</Feld>
<Feld
id="organisation"
beschriftung="Behörde, Heim oder Kanzlei"
fehler={zustand.felder?.organisation}
>
<Input name="organisation" defaultValue={wert('organisation')} autoComplete="organization" />
</Feld>
<Feld
id="rolle"
beschriftung="Rolle"
pflicht
erklaerung="Nach der Rolle wird das Verzeichnis gruppiert."
fehler={zustand.felder?.rolle}
>
<Select name="rolle" defaultValue={wert('rolle') || 'SONSTIGES'}>
{KONTAKT_ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{KONTAKT_ROLLE_BESCHRIFTUNG[rolle]}
</option>
))}
</Select>
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen dort"
erklaerung="Das Zeichen, unter dem diese Stelle den Vorgang führt – nicht mein eigenes."
fehler={zustand.felder?.aktenzeichen}
>
<Input name="aktenzeichen" defaultValue={wert('aktenzeichen')} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Wie erreiche ich die Stelle?">
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
<Input name="telefon" type="tel" defaultValue={wert('telefon')} autoComplete="tel" />
</Feld>
<Feld id="mobil" beschriftung="Mobil" fehler={zustand.felder?.mobil}>
<Input name="mobil" type="tel" defaultValue={wert('mobil')} />
</Feld>
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
<Input name="email" type="email" defaultValue={wert('email')} autoComplete="email" />
</Feld>
<Feld id="fax" beschriftung="Fax" fehler={zustand.felder?.fax}>
<Input name="fax" type="tel" defaultValue={wert('fax')} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Anschrift">
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input name="strasse" defaultValue={wert('strasse')} autoComplete="street-address" />
</Feld>
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input name="plz" defaultValue={wert('plz')} autoComplete="postal-code" inputMode="numeric" />
</Feld>
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
<Input name="ort" defaultValue={wert('ort')} autoComplete="address-level2" />
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Sprechzeiten, Durchwahl der Vertretung, wie das letzte Gespräch lief. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={wert('notizen')} rows={4} />
</Feld>
<SpeichernKnopf neu={!kontakt} />
</form>
)
}
function SpeichernKnopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? 'Wird gespeichert …' : neu ? 'Kontakt anlegen' : 'Änderungen speichern'}
</Button>
)
}
@@ -0,0 +1,368 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, ChevronDown } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import {
DAUER_SCHNELLWAHL,
KONTAKT_ARTEN,
KONTAKT_ARTEN_SCHNELLWAHL,
KONTAKT_ART_BESCHRIFTUNG,
} from '@/lib/validierung/kontakte'
import { protokollSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
import { cn } from '@/lib/utils'
/**
* Die Schnellerfassung.
*
* Sie muss am Handy in unter 20 Sekunden zu bedienen sein - im Flur des
* Pflegeheims, direkt nach dem Besuch, mit einer Hand. Deshalb:
*
* - Das Datum ist auf heute vorbelegt.
* - Die Art ist eine Knopfreihe, kein Auswahlmenue.
* - Die Dauer hat vier Schnellwahlen und daneben ein freies Feld.
* - Ein Haekchen fuer den persoenlichen Kontakt - die Angabe, auf die es
* im Jahresbericht nach § 1863 BGB ankommt.
* - Ein grosses Freitextfeld.
*
* Alles Weitere steckt eingeklappt in "Weitere Angaben". Speichern ist der
* einzige auffaellige Knopf auf der Seite.
*/
export type ProtokollWerte = {
id: string
datum: string
art: string
dauerMinuten: number | null
gespraechspartner: string | null
mitBetreuter: boolean
kontaktId: string | null
vorgangId: string | null
inhalt: string | null
wuensche: string | null
folgeaufgaben: string | null
}
export type Auswahleintrag = { id: string; bezeichnung: string }
const ANFANG: KontaktZustand = {}
/** Die uebrigen Arten, die nicht in der grossen Reihe stehen. */
const WEITERE_ARTEN = KONTAKT_ARTEN.filter(
(art) => !(KONTAKT_ARTEN_SCHNELLWAHL as readonly string[]).includes(art),
)
export function ProtokollErfassung({
betreuteId,
heute,
kontakte,
vorgaenge,
protokoll,
weiter,
}: {
betreuteId: string
/** Vorbelegung fuer das Datumsfeld im Format JJJJ-MM-TT. */
heute: string
kontakte: Auswahleintrag[]
vorgaenge: Auswahleintrag[]
protokoll?: ProtokollWerte
/** Wohin nach dem Speichern? Muss ein Pfad dieser Anwendung sein. */
weiter?: string
}) {
const [zustand, aktion] = useActionState(protokollSpeichern, ANFANG)
const vorgabeDauer =
zustand.werte?.dauerMinuten ??
(protokoll?.dauerMinuten !== null && protokoll?.dauerMinuten !== undefined
? String(protokoll.dauerMinuten)
: '')
const [dauer, setDauer] = React.useState<string>(vorgabeDauer)
const vorgabeArt = zustand.werte?.art ?? protokoll?.art ?? 'BESUCH'
const vorgabeDatum = zustand.werte?.datum ?? protokoll?.datum ?? heute
const vorgabeMitBetreuter =
zustand.werte !== undefined
? zustand.werte.mitBetreuter === 'on'
: (protokoll?.mitBetreuter ?? true)
const mehrOffen = Boolean(
protokoll?.gespraechspartner ||
protokoll?.kontaktId ||
protokoll?.vorgangId ||
protokoll?.wuensche ||
protokoll?.folgeaufgaben ||
zustand.felder?.gespraechspartner ||
zustand.felder?.kontaktId ||
zustand.felder?.vorgangId,
)
return (
<form action={aktion} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{protokoll && <input type="hidden" name="id" value={protokoll.id} />}
{weiter && <input type="hidden" name="weiter" value={weiter} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{/* --- Datum ---------------------------------------------------------- */}
<Feld id="datum" beschriftung="Wann war das?" pflicht fehler={zustand.felder?.datum}>
<Input name="datum" type="date" defaultValue={vorgabeDatum} required max={heute} />
</Feld>
{/* --- Art als Knopfreihe --------------------------------------------- */}
<fieldset>
<legend className="mb-2 text-sm font-medium">Wie fand der Kontakt statt?</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{KONTAKT_ARTEN_SCHNELLWAHL.map((art) => (
<ArtKnopf key={art} art={art} gross vorgabe={vorgabeArt} />
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{WEITERE_ARTEN.map((art) => (
<ArtKnopf key={art} art={art} vorgabe={vorgabeArt} />
))}
</div>
{zustand.felder?.art && (
<p className="mt-2 text-sm font-medium text-destructive">{zustand.felder.art}</p>
)}
</fieldset>
{/* --- Dauer ---------------------------------------------------------- */}
<fieldset>
<legend className="mb-2 text-sm font-medium">Wie lange? (optional)</legend>
<div className="flex flex-wrap items-center gap-2">
{DAUER_SCHNELLWAHL.map((minuten) => {
const gewaehlt = dauer === String(minuten)
return (
<button
key={minuten}
type="button"
aria-pressed={gewaehlt}
onClick={() => setDauer(gewaehlt ? '' : String(minuten))}
className={cn(
'min-h-11 min-w-16 rounded-md border px-4 text-base font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
gewaehlt
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background hover:bg-accent',
)}
>
{minuten} min
</button>
)
})}
<label htmlFor="dauerMinuten" className="sr-only">
Dauer in Minuten
</label>
<Input
id="dauerMinuten"
name="dauerMinuten"
type="number"
inputMode="numeric"
min={1}
max={1440}
step={1}
placeholder="frei"
className="w-28"
value={dauer}
onChange={(e) => setDauer(e.target.value)}
aria-invalid={zustand.felder?.dauerMinuten ? true : undefined}
/>
</div>
{zustand.felder?.dauerMinuten && (
<p className="mt-2 text-sm font-medium text-destructive">
{zustand.felder.dauerMinuten}
</p>
)}
</fieldset>
{/* --- Persoenlicher Kontakt ------------------------------------------ */}
<label
htmlFor="mitBetreuter"
className={cn(
'flex min-h-14 cursor-pointer items-center gap-3 rounded-md border border-input',
'bg-background px-4 py-3 hover:bg-accent',
)}
>
<Checkbox
id="mitBetreuter"
name="mitBetreuter"
defaultChecked={vorgabeMitBetreuter}
className="size-6"
/>
<span>
<span className="block font-medium">Persönlicher Kontakt zur betreuten Person</span>
<span className="block text-sm text-muted-foreground">
Zählt für den Jahresbericht nach § 1863 BGB.
</span>
</span>
</label>
{/* --- Freitext -------------------------------------------------------- */}
<Feld
id="inhalt"
beschriftung="Was war?"
erklaerung="Ein oder zwei Sätze reichen. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.inhalt}
>
<Textarea
name="inhalt"
rows={6}
className="min-h-36"
defaultValue={zustand.werte?.inhalt ?? protokoll?.inhalt ?? ''}
placeholder="Besuch im Heim, Mutter war wach und hat gegessen. Sauerstoff läuft. Pflegedienstleitung will sich wegen der Höherstufung melden."
/>
</Feld>
{/* --- Alles Weitere, eingeklappt -------------------------------------- */}
<details className="rounded-md border" open={mehrOffen}>
<summary
className={cn(
'flex min-h-11 cursor-pointer list-none items-center justify-between gap-2 px-4 py-3',
'text-sm font-medium marker:content-none hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
Weitere Angaben (optional)
<ChevronDown className="size-4 shrink-0" aria-hidden="true" />
</summary>
<div className="space-y-5 border-t p-4">
<Feld
id="gespraechspartner"
beschriftung="Mit wem gesprochen?"
fehler={zustand.felder?.gespraechspartner}
>
<Input
name="gespraechspartner"
defaultValue={
zustand.werte?.gespraechspartner ?? protokoll?.gespraechspartner ?? ''
}
/>
</Feld>
<Feld
id="kontaktId"
beschriftung="Zu welchem Kontakt gehört das?"
fehler={zustand.felder?.kontaktId}
>
<Select
name="kontaktId"
defaultValue={zustand.werte?.kontaktId ?? protokoll?.kontaktId ?? ''}
>
<option value="">– keinem bestimmten –</option>
{kontakte.map((k) => (
<option key={k.id} value={k.id}>
{k.bezeichnung}
</option>
))}
</Select>
</Feld>
<Feld
id="vorgangId"
beschriftung="Zu welchem Vorgang gehört das?"
fehler={zustand.felder?.vorgangId}
>
<Select
name="vorgangId"
defaultValue={zustand.werte?.vorgangId ?? protokoll?.vorgangId ?? ''}
>
<option value="">– keinem bestimmten –</option>
{vorgaenge.map((v) => (
<option key={v.id} value={v.id}>
{v.bezeichnung}
</option>
))}
</Select>
</Feld>
<Feld
id="wuensche"
beschriftung="Geäußerte Wünsche"
erklaerung="Der Wille der betreuten Person ist seit 2023 Leitschnur des Betreuerhandelns (§ 1821 BGB). Was hier steht, gehört in den Jahresbericht."
fehler={zustand.felder?.wuensche}
>
<Textarea
name="wuensche"
rows={3}
defaultValue={zustand.werte?.wuensche ?? protokoll?.wuensche ?? ''}
/>
</Feld>
<Feld
id="folgeaufgaben"
beschriftung="Was folgt daraus?"
erklaerung="Wird nicht automatisch zu einer Aufgabe – für eine Frist bitte zusätzlich eine anlegen."
fehler={zustand.felder?.folgeaufgaben}
>
<Textarea
name="folgeaufgaben"
rows={3}
defaultValue={zustand.werte?.folgeaufgaben ?? protokoll?.folgeaufgaben ?? ''}
/>
</Feld>
</div>
</details>
<SpeichernKnopf neu={!protokoll} />
</form>
)
}
function ArtKnopf({
art,
vorgabe,
gross,
}: {
art: string
vorgabe: string
gross?: boolean
}) {
return (
<label className="block cursor-pointer">
<input
type="radio"
name="art"
value={art}
defaultChecked={vorgabe === art}
className="peer sr-only"
/>
<span
className={cn(
'flex items-center justify-center rounded-md border border-input bg-background',
'text-center font-medium transition-colors hover:bg-accent',
'peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground',
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2',
gross ? 'min-h-14 px-3 text-base' : 'min-h-11 px-3 text-sm',
)}
>
{KONTAKT_ART_BESCHRIFTUNG[art as keyof typeof KONTAKT_ART_BESCHRIFTUNG] ?? art}
</span>
</label>
)
}
function SpeichernKnopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" className="w-full text-base" disabled={pending}>
{pending ? 'Wird gespeichert …' : neu ? 'Speichern' : 'Änderungen speichern'}
</Button>
)
}
+2
View File
@@ -10,6 +10,7 @@ import {
FolderKanban,
HeartPulse,
Home,
IdCard,
Landmark,
ScrollText,
Shield,
@@ -38,6 +39,7 @@ export type NavEintrag = {
export const NAV_STRUKTUR: Omit<NavEintrag, 'zaehler' | 'dringend'>[] = [
{ titel: 'Heute', href: '/heute', icon: Home, modul: 'STAMMDATEN' },
{ titel: 'Stammdaten', href: '/stammdaten', icon: IdCard, modul: 'STAMMDATEN' },
{ titel: 'Fristen', href: '/fristen', icon: CalendarClock, modul: 'FRISTEN' },
{ titel: 'Vorgänge', href: '/vorgaenge', icon: FolderKanban, modul: 'VORGAENGE' },
{ titel: 'Dokumente', href: '/dokumente', icon: FileText, modul: 'DOKUMENTE' },
+106
View File
@@ -0,0 +1,106 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4',
'max-h-[90dvh] overflow-y-auto border bg-background p-5 shadow-lg sm:rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
)}
>
<X className="size-5" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col gap-1.5 pr-8', className)} {...props} />
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Fortschrittsbalken, z.B. "7 von 12 Unterlagen geliefert".
*
* Der Wert steht immer auch als Text daneben - ein Balken allein ist fuer
* Screenreader und bei schlechtem Kontrast keine Information.
*/
export function Fortschritt({
wert,
gesamt,
beschriftung,
className,
}: {
wert: number
gesamt: number
beschriftung?: string
className?: string
}) {
const anteil = gesamt > 0 ? Math.round((wert / gesamt) * 100) : 0
const fertig = gesamt > 0 && wert >= gesamt
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{beschriftung ?? 'Fortschritt'}</span>
<span className="font-medium tabular-nums">
{wert} von {gesamt}
</span>
</div>
<div
role="progressbar"
aria-valuenow={wert}
aria-valuemin={0}
aria-valuemax={gesamt}
aria-label={beschriftung ?? 'Fortschritt'}
className="h-2 w-full overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
'h-full rounded-full transition-all',
fertig ? 'bg-ampel-gruen' : 'bg-primary',
)}
style={{ width: `${anteil}%` }}
/>
</div>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useActionState, useRef, useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { aufgabeAbhaken, aufgabeAnlegen } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn } from '@/lib/utils'
/**
* Die Aufgabenliste eines Vorgangs.
*
* Aus einer Vorlage kommt sie vorbefüllt und in der fachlich richtigen
* Reihenfolge. Abgehakt wird direkt in der Liste - ein Formular je Zeile,
* damit es auch ohne JavaScript funktioniert.
*/
export type AufgabenZeile = {
id: string
titel: string
beschreibung: string | null
erledigt: boolean
faelligAm: Date | null
}
export function AufgabenListe({
vorgangId,
aufgaben,
darfSchreiben,
}: {
vorgangId: string
aufgaben: AufgabenZeile[]
darfSchreiben: boolean
}) {
const erledigt = aufgaben.filter((a) => a.erledigt).length
return (
<div className="space-y-4">
{aufgaben.length > 0 && (
<Fortschritt
wert={erledigt}
gesamt={aufgaben.length}
beschriftung="Aufgaben erledigt"
/>
)}
{aufgaben.length === 0 ? (
<p className="rounded-md border border-dashed px-4 py-6 text-sm text-muted-foreground">
Für diesen Vorgang ist noch kein Handgriff notiert. Trag den nächsten ein - dann
erscheint er auch auf der Startseite.
</p>
) : (
<ul className="divide-y rounded-lg border">
{aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<AufgabenZeileAnzeige aufgabe={aufgabe} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
)}
{darfSchreiben && <AufgabeHinzufuegen vorgangId={vorgangId} />}
</div>
)
}
function AufgabenZeileAnzeige({
aufgabe,
darfSchreiben,
}: {
aufgabe: AufgabenZeile
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(aufgabeAbhaken, {})
const formular = useRef<HTMLFormElement>(null)
return (
<form ref={formular} action={aktion} className="flex items-start gap-3 p-3">
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="erledigt" value={aufgabe.erledigt ? 'false' : 'true'} />
<Checkbox
id={`aufgabe-${aufgabe.id}`}
checked={aufgabe.erledigt}
disabled={!darfSchreiben}
// Das Absenden erledigt das Formular; onChange ist nur der Auslöser.
onChange={() => formular.current?.requestSubmit()}
className="mt-1"
/>
<div className="min-w-0 flex-1 space-y-1">
<label
htmlFor={`aufgabe-${aufgabe.id}`}
className={cn(
'block cursor-pointer text-base font-medium',
aufgabe.erledigt && 'text-muted-foreground line-through',
)}
>
{aufgabe.titel}
</label>
{aufgabe.beschreibung && (
<p className="text-sm text-muted-foreground">{aufgabe.beschreibung}</p>
)}
{!aufgabe.erledigt && <Faelligkeit datum={aufgabe.faelligAm} />}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
</div>
{/* Ohne JavaScript bleibt dieser Knopf der Weg zum Abhaken. */}
<noscript>
<Button type="submit" variant="outline" size="sm">
{aufgabe.erledigt ? 'Öffnen' : 'Erledigt'}
</Button>
</noscript>
</form>
)
}
function AufgabeHinzufuegen({ vorgangId }: { vorgangId: string }) {
const [offen, setOffen] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(aufgabeAnlegen, {})
if (!offen) {
return (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Aufgabe hinzufügen
</Button>
)
}
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="vorgangId" value={vorgangId} />
<Feld id="aufgabe-titel" beschriftung="Was ist zu tun?" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" required maxLength={200} />
</Feld>
<Feld
id="aufgabe-beschreibung"
beschriftung="Erklärung"
erklaerung="Woran denkst du in sechs Wochen nicht mehr? Das gehört hierher."
fehler={zustand.felder?.beschreibung}
>
<Textarea name="beschreibung" rows={3} maxLength={2000} />
</Feld>
<Feld id="aufgabe-faellig" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" />
</Feld>
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Hinzufügen'}
</Button>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Badge } from '@/components/ui/badge'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
/**
* Fälligkeit als Ampel - immer mit Text.
*
* Farbe allein wäre keine Information: Wer rot und grün nicht unterscheiden
* kann, sieht sonst nur zwei graue Punkte. Deshalb steht neben jeder Farbe,
* was sie bedeutet.
*/
export function ampelStufe(
datum: Date | null,
): { variante: 'rot' | 'orange' | 'gelb' | 'gruen'; text: string } | null {
if (!datum) return null
const tage = tageBis(datum)
if (tage < 0) return { variante: 'rot', text: `überfällig, ${relativeTage(tage)}` }
if (tage <= 7) return { variante: 'orange', text: `fällig ${relativeTage(tage)}` }
if (tage <= 31) return { variante: 'gelb', text: `fällig ${relativeTage(tage)}` }
return { variante: 'gruen', text: `fällig am ${formatDatum(datum)}` }
}
export function Faelligkeit({ datum }: { datum: Date | null }) {
const stufe = ampelStufe(datum)
if (!stufe) return null
return (
<Badge variant={stufe.variante}>
<span className="sr-only">Fälligkeit: </span>
{stufe.text}
</Badge>
)
}
@@ -0,0 +1,109 @@
'use client'
import { useActionState, useState } from 'react'
import { AlertCircle, Scale } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { genehmigungAnlegen } from '@/server/actions/genehmigungen'
import { GENEHMIGUNG_WARNUNG, RECHTSGRUNDLAGEN } from '@/lib/vorgaenge/genehmigungskette'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Genehmigungsverfahren zu einem Vorgang anlegen.
*
* Die Auswahl der Rechtsgrundlage ist der eigentliche Zweck dieses
* Formulars: Jede Vorschrift steht hier mit einer Erklärung in normalen
* Worten und mit Beispielen, damit man nicht raten muss. Die Zuordnung im
* Einzelfall bleibt Sache des Rechtspflegers.
*/
export function GenehmigungAnlegen({ vorgangId }: { vorgangId: string }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
genehmigungAnlegen,
{},
)
const [gewaehlt, setGewaehlt] = useState(RECHTSGRUNDLAGEN[0]?.wert ?? '')
const erklaerung = RECHTSGRUNDLAGEN.find((r) => r.wert === gewaehlt)
return (
<form action={aktion} className="space-y-5 rounded-lg border p-4">
<input type="hidden" name="vorgangId" value={vorgangId} />
<div className="flex items-start gap-2">
<Scale className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>
<h3 className="font-semibold">Genehmigungsverfahren anlegen</h3>
<p className="text-sm text-muted-foreground">{GENEHMIGUNG_WARNUNG}</p>
</div>
</div>
<Feld
id="genehmigung-titel"
beschriftung="Welches Geschäft soll genehmigt werden?"
fehler={zustand.felder?.titel}
pflicht
>
<Input name="titel" required maxLength={200} placeholder="z. B. Verkauf des Hauses" />
</Feld>
<Feld
id="genehmigung-rechtsgrundlage"
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
pflicht
>
<Select
name="rechtsgrundlage"
value={gewaehlt}
onChange={(e) => setGewaehlt(e.target.value)}
>
{RECHTSGRUNDLAGEN.map((grundlage) => (
<option key={grundlage.wert} value={grundlage.wert}>
{grundlage.wert} — {grundlage.kurz}
</option>
))}
</Select>
</Feld>
{erklaerung && (
<div className="space-y-2 rounded-md bg-muted/50 p-3">
<p className="text-sm">{erklaerung.erklaerung}</p>
<ul className="list-inside list-disc text-sm text-muted-foreground">
{erklaerung.beispiele.map((beispiel) => (
<li key={beispiel}>{beispiel}</li>
))}
</ul>
<Rechtshinweis rechtsgrundlage={erklaerung.wert} kurz />
</div>
)}
<Feld
id="genehmigung-notizen"
beschriftung="Notizen"
erklaerung="Werden verschlüsselt gespeichert - z. B. was der Rechtspfleger am Telefon gesagt hat."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" rows={3} />
</Feld>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Legt an…' : 'Genehmigungsverfahren anlegen'}
</Button>
</form>
)
}
@@ -0,0 +1,289 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, Check, CircleDashed, FileWarning, Scale, XCircle } from 'lucide-react'
import type { GenehmigungStatus } from '@prisma/client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
beschlussVerknuepfen,
genehmigungStatusWechseln,
} from '@/server/actions/genehmigungen'
import {
darfStatusWechseln,
GENEHMIGUNG_STATUS_BESCHRIFTUNG,
GENEHMIGUNG_STUFEN,
GENEHMIGUNG_WARNUNG,
moeglicheStatusWechsel,
rechtsgrundlageFinden,
stufeVon,
} from '@/lib/vorgaenge/genehmigungskette'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn, formatDatum } from '@/lib/utils'
/**
* Die Genehmigungskette als Stufenanzeige.
*
* Über allem steht der Warnhinweis: Die Genehmigung muss VOR dem
* Rechtsgeschäft vorliegen (§ 1858 BGB). Wer die Reihenfolge dreht,
* schließt einen schwebend unwirksamen Vertrag und haftet unter Umständen
* persönlich. Die Anzeige macht deshalb sichtbar, wo man steht - und die
* Auswahl bietet nur an, was fachlich als Nächstes möglich ist.
*/
export type GenehmigungsAnzeige = {
id: string
titel: string
rechtsgrundlage: string
status: GenehmigungStatus
mitteilungAm: Date | null
antragAm: Date | null
anhoerungAm: Date | null
beschlussAm: Date | null
rechtskraftAm: Date | null
vollzugAm: Date | null
beschlussDokumentId: string | null
beschlussDokumentTitel: string | null
/** Dokumente dieser betreuten Person, aus denen der Beschluss gewählt wird. */
dokumente: { id: string; titel: string }[]
}
export function GenehmigungsKette({
genehmigung,
darfSchreiben,
}: {
genehmigung: GenehmigungsAnzeige
darfSchreiben: boolean
}) {
const erklaerung = rechtsgrundlageFinden(genehmigung.rechtsgrundlage)
const versagt = genehmigung.status === 'VERSAGT'
const aktuelleStufe = stufeVon(genehmigung.status)
const vollzugGesperrt = !darfStatusWechseln(genehmigung, 'VOLLZOGEN').erlaubt
return (
<div className="space-y-5">
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>Genehmigungspflichtiger Vorgang</AlertTitle>
<AlertDescription>{GENEHMIGUNG_WARNUNG}</AlertDescription>
</Alert>
<div className="space-y-1">
<h3 className="text-base font-semibold">{genehmigung.titel}</h3>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{genehmigung.rechtsgrundlage}</span>
{erklaerung && <> · {erklaerung.kurz}</>}
</p>
{erklaerung && <p className="text-sm text-muted-foreground">{erklaerung.erklaerung}</p>}
</div>
{versagt && (
<Alert variant="fehler">
<XCircle aria-hidden="true" />
<AlertTitle>Das Gericht hat die Genehmigung versagt</AlertTitle>
<AlertDescription>
Das Rechtsgeschäft darf nicht ausgeführt werden. Ein geänderter Entwurf kann neu
vorgelegt werden - dann beginnt die Kette wieder bei „Geplant“.
</AlertDescription>
</Alert>
)}
{/* Stufenanzeige: senkrecht, damit sie am Handy nicht seitlich scrollt. */}
<ol className="space-y-0">
{GENEHMIGUNG_STUFEN.map((stufe, index) => {
const erreicht = !versagt && aktuelleStufe >= index
const aktuell = !versagt && aktuelleStufe === index
const datum = stufe.datumsfeld ? genehmigung[stufe.datumsfeld] : null
const letzte = index === GENEHMIGUNG_STUFEN.length - 1
return (
<li key={stufe.status} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-full border-2',
erreicht
? 'border-ampel-gruen bg-ampel-gruen/15 text-ampel-gruen'
: 'border-muted-foreground/30 text-muted-foreground',
)}
aria-hidden="true"
>
{erreicht ? <Check className="size-4" /> : <CircleDashed className="size-4" />}
</span>
{!letzte && (
<span
className={cn(
'w-0.5 flex-1',
erreicht ? 'bg-ampel-gruen/40' : 'bg-muted-foreground/20',
)}
aria-hidden="true"
/>
)}
</div>
<div className={cn('min-w-0 flex-1 pb-5', letzte && 'pb-0')}>
<div className="flex flex-wrap items-center gap-2">
<span className={cn('font-medium', !erreicht && 'text-muted-foreground')}>
{stufe.beschriftung}
</span>
{erreicht && <span className="sr-only">(erreicht)</span>}
{aktuell && <Badge variant="secondary">Aktueller Stand</Badge>}
{datum && (
<span className="text-sm tabular-nums text-muted-foreground">
{formatDatum(datum)}
</span>
)}
</div>
<p className="mt-0.5 text-sm text-muted-foreground">{stufe.erklaerung}</p>
</div>
</li>
)
})}
</ol>
{vollzugGesperrt && genehmigung.status !== 'VOLLZOGEN' && (
<Alert variant="hinweis">
<FileWarning aria-hidden="true" />
<AlertTitle>Vollzug ist gesperrt</AlertTitle>
<AlertDescription>
{genehmigung.beschlussDokumentId
? 'Erst wird die Kette bis „Rechtskräftig“ durchlaufen, dann lässt sich der Vollzug eintragen.'
: 'Solange kein Genehmigungsbeschluss als Dokument hinterlegt ist, kann der Vollzug nicht eingetragen werden. Ohne Beleg gibt es gegenüber Gericht und Miterben keinen Nachweis.'}
</AlertDescription>
</Alert>
)}
{darfSchreiben && (
<div className="grid gap-5 md:grid-cols-2">
<StatusFormular genehmigung={genehmigung} />
<BeschlussFormular genehmigung={genehmigung} />
</div>
)}
<Rechtshinweis rechtsgrundlage={genehmigung.rechtsgrundlage} />
</div>
)
}
function StatusFormular({ genehmigung }: { genehmigung: GenehmigungsAnzeige }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
genehmigungStatusWechseln,
{},
)
const moeglich = moeglicheStatusWechsel(genehmigung)
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="genehmigungId" value={genehmigung.id} />
<h4 className="font-semibold">Nächste Stufe eintragen</h4>
{moeglich.length === 0 ? (
<p className="text-sm text-muted-foreground">
Von „{GENEHMIGUNG_STATUS_BESCHRIFTUNG[genehmigung.status]}“ aus geht es hier nicht
weiter.
</p>
) : (
<>
<Feld id={`gstatus-${genehmigung.id}`} beschriftung="Neuer Stand">
<Select name="status" defaultValue={moeglich[0]}>
{moeglich.map((wert) => (
<option key={wert} value={wert}>
{GENEHMIGUNG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id={`gdatum-${genehmigung.id}`}
beschriftung="Datum"
erklaerung="Leer lassen heißt: heute."
>
<Input name="datum" type="date" />
</Feld>
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Stand eintragen'}
</Button>
</>
)}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
function BeschlussFormular({ genehmigung }: { genehmigung: GenehmigungsAnzeige }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
beschlussVerknuepfen,
{},
)
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="genehmigungId" value={genehmigung.id} />
<h4 className="font-semibold">Beschlussdokument</h4>
<p className="text-sm text-muted-foreground">
{genehmigung.beschlussDokumentTitel
? `Hinterlegt: ${genehmigung.beschlussDokumentTitel}`
: 'Noch kein Beschluss hinterlegt. Solange bleibt der Vollzug gesperrt.'}
</p>
{genehmigung.dokumente.length === 0 ? (
<p className="text-sm text-muted-foreground">
In der Akte liegt noch kein Dokument. Lade den Beschluss zuerst unter „Dokumente“
hoch.
</p>
) : (
<>
<Feld
id={`beschluss-${genehmigung.id}`}
beschriftung="Dokument wählen"
erklaerung="Am besten die Ausfertigung mit Rechtskraftvermerk."
>
<Select name="dokumentId" defaultValue={genehmigung.beschlussDokumentId ?? ''}>
<option value="">— keines —</option>
{genehmigung.dokumente.map((dokument) => (
<option key={dokument.id} value={dokument.id}>
{dokument.titel}
</option>
))}
</Select>
</Feld>
<Button type="submit" variant="outline" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Übernehmen'}
</Button>
</>
)}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
@@ -0,0 +1,115 @@
'use client'
import { useActionState, useState } from 'react'
import { Check, Copy, Mail } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { kontaktVermerken } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Sachstandsanfrage erzeugen.
*
* Der Text wird auf dem Server gebaut (dort liegen Aktenzeichen und
* Stammdaten) und hier nur noch zum Kopieren angeboten. Kein PDF: Diese
* Nachrichten gehen meist per E-Mail oder über ein Behördenportal raus.
*
* Wer die Anfrage abgeschickt hat, vermerkt das gleich als letzten
* Kontakt - sonst stimmt beim nächsten Mal das Datum im Text nicht.
*/
export function SachstandsanfrageKnopf({
vorgangId,
betreff,
text,
darfSchreiben,
}: {
vorgangId: string
betreff: string
text: string
darfSchreiben: boolean
}) {
const [offen, setOffen] = useState(false)
const [kopiert, setKopiert] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
kontaktVermerken,
{},
)
async function kopieren() {
try {
await navigator.clipboard.writeText(text)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 3000)
} catch {
// Ohne Zwischenablage-Berechtigung bleibt der Text im Feld stehen
// und lässt sich von Hand markieren - kein Grund für eine Fehlermeldung.
setKopiert(false)
}
}
return (
<Dialog open={offen} onOpenChange={setOffen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Mail aria-hidden="true" />
Sachstandsanfrage erzeugen
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Sachstandsanfrage</DialogTitle>
<DialogDescription>
Fertiger Text zum Kopieren. Betreff: {betreff}
</DialogDescription>
</DialogHeader>
<label htmlFor="sachstand-text" className="sr-only">
Text der Sachstandsanfrage
</label>
<Textarea
id="sachstand-text"
readOnly
value={text}
rows={16}
className="font-mono text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={kopieren}>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
{darfSchreiben && (
<form action={aktion}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<Button type="submit" variant="outline" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Abgeschickt - als letzten Kontakt vermerken'}
</Button>
</form>
)}
</div>
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle } from 'lucide-react'
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_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn } from '@/lib/utils'
/**
* Statuswechsel über ein Auswahlfeld.
*
* Bewusst kein Drag & Drop: Am Handy - und dort benutze ich die App im
* Heim und auf dem Amt - ist Ziehen unzuverlässig, und ein Vorgang, der
* versehentlich in die falsche Spalte rutscht, fällt niemandem auf. Ein
* Auswahlfeld mit Knopf trifft man auch mit kalten Fingern.
*/
export function StatusWechsler({
vorgangId,
status,
klein = false,
className,
}: {
vorgangId: string
status: VorgangStatus
klein?: boolean
className?: string
}) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
vorgangStatusWechseln,
{},
)
return (
<form action={aktion} className={cn('space-y-2', className)}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<label htmlFor={`status-${vorgangId}`} className="sr-only">
Status des Vorgangs
</label>
<Select
id={`status-${vorgangId}`}
name="status"
defaultValue={status}
className={klein ? 'h-11 text-sm' : undefined}
>
{VORGANG_STATUS.map((wert) => (
<option key={wert} value={wert}>
{VORGANG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</div>
<Button type="submit" variant="outline" disabled={laeuft} size={klein ? 'sm' : 'default'}>
{laeuft ? 'Ändert…' : 'Übernehmen'}
</Button>
</div>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
@@ -0,0 +1,210 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Columns3, List, Scale } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { KATEGORIE_BESCHRIFTUNG, VORGANG_STATUS } from '@/lib/validierung/vorgaenge'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
import { cn, formatDatum } from '@/lib/utils'
/**
* Vorgangsliste mit Umschalter Liste ⇄ Kanban.
*
* Die Kanban-Ansicht ist am Handy KEIN waagerechtes Gescrolle, sondern
* eine Folge gestapelter Abschnitte. Seitlich scrollende Spalten sind auf
* einem 5-Zoll-Schirm unbenutzbar; ab Tabletbreite werden daraus Spalten.
*/
export type VorgangsZeile = {
id: string
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
prioritaet: number
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
faelligAm: Date | null
letzterKontaktAm: Date | null
genehmigungspflichtig: boolean
aufgabenOffen: number
aufgabenGesamt: number
}
export function VorgaengeAnsicht({
vorgaenge,
darfSchreiben,
}: {
vorgaenge: VorgangsZeile[]
darfSchreiben: boolean
}) {
const [ansicht, setAnsicht] = useState<'liste' | 'kanban'>('liste')
return (
<div className="space-y-4">
<div
className="flex flex-wrap items-center gap-2"
role="group"
aria-label="Darstellung wählen"
>
<Button
type="button"
variant={ansicht === 'liste' ? 'default' : 'outline'}
size="sm"
aria-pressed={ansicht === 'liste'}
onClick={() => setAnsicht('liste')}
>
<List aria-hidden="true" />
Liste
</Button>
<Button
type="button"
variant={ansicht === 'kanban' ? 'default' : 'outline'}
size="sm"
aria-pressed={ansicht === 'kanban'}
onClick={() => setAnsicht('kanban')}
>
<Columns3 aria-hidden="true" />
Nach Status
</Button>
<span className="ml-auto text-sm text-muted-foreground">
{vorgaenge.length} {vorgaenge.length === 1 ? 'Vorgang' : 'Vorgänge'}
</span>
</div>
{ansicht === 'liste' ? (
<ul className="space-y-3">
{vorgaenge.map((vorgang) => (
<li key={vorgang.id}>
<VorgangsKarte
vorgang={vorgang}
darfSchreiben={darfSchreiben}
mitStatusetikett
/>
</li>
))}
</ul>
) : (
<div className="grid gap-5 lg:grid-cols-5">
{VORGANG_STATUS.map((status) => {
const spalte = vorgaenge.filter((v) => v.status === status)
return (
<section key={status} aria-labelledby={`spalte-${status}`} className="space-y-3">
<h2
id={`spalte-${status}`}
className="flex items-baseline justify-between gap-2 border-b pb-2 text-sm font-semibold"
>
{VORGANG_STATUS_BESCHRIFTUNG[status]}
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{spalte.length}
</span>
</h2>
{spalte.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
Nichts in diesem Status.
</p>
) : (
<ul className="space-y-3">
{spalte.map((vorgang) => (
<li key={vorgang.id}>
<VorgangsKarte vorgang={vorgang} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
)}
</section>
)
})}
</div>
)}
</div>
)
}
function VorgangsKarte({
vorgang,
darfSchreiben,
mitStatusetikett = false,
}: {
vorgang: VorgangsZeile
darfSchreiben: boolean
mitStatusetikett?: boolean
}) {
return (
<Card className={cn(vorgang.prioritaet === 1 && 'border-l-4 border-l-ampel-orange')}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<Link
href={`/vorgaenge/${vorgang.id}`}
className="min-w-0 text-base font-semibold hover:underline"
>
{vorgang.titel}
</Link>
{vorgang.prioritaet === 1 && <Badge variant="orange">Hohe Priorität</Badge>}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
{mitStatusetikett && (
<Badge variant="secondary">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</Badge>
)}
<Faelligkeit datum={vorgang.faelligAm} />
{vorgang.genehmigungspflichtig && (
<Badge variant="gelb">
<Scale className="size-3" aria-hidden="true" />
Genehmigungspflichtig
</Badge>
)}
</div>
{vorgang.naechsterSchritt && (
<p className="text-sm">
<span className="text-muted-foreground">Nächster Schritt: </span>
{vorgang.naechsterSchritt}
</p>
)}
<dl className="flex flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
{vorgang.gegenstelle && (
<div className="flex gap-1">
<dt>Gegenstelle:</dt>
<dd className="text-foreground">{vorgang.gegenstelle}</dd>
</div>
)}
{vorgang.aktenzeichen && (
<div className="flex gap-1">
<dt>Az.:</dt>
<dd className="text-foreground">{vorgang.aktenzeichen}</dd>
</div>
)}
{vorgang.aufgabenGesamt > 0 && (
<div className="flex gap-1">
<dt>Aufgaben:</dt>
<dd className="text-foreground tabular-nums">
{vorgang.aufgabenGesamt - vorgang.aufgabenOffen} von {vorgang.aufgabenGesamt}{' '}
erledigt
</dd>
</div>
)}
{vorgang.status === 'WARTET_AUF_DRITTE' && (
<div className="flex gap-1">
<dt>Letzter Kontakt:</dt>
<dd className="text-foreground">{formatDatum(vorgang.letzterKontaktAm)}</dd>
</div>
)}
</dl>
{darfSchreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,70 @@
'use client'
import { useActionState, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { vorgangInPapierkorb } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Vorgang in den Papierkorb legen.
*
* Bewusst mit Rückfrage: Eine Fallakte enthält Fristen und Aufgaben, an
* denen Ansprüche hängen. Endgültig gelöscht wird sie erst nach 30 Tagen
* und nur aus dem Papierkorb heraus.
*/
export function VorgangLoeschen({ vorgangId, titel }: { vorgangId: string; titel: string }) {
const [offen, setOffen] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
vorgangInPapierkorb,
{},
)
return (
<Dialog open={offen} onOpenChange={setOffen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Trash2 aria-hidden="true" />
In den Papierkorb
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Vorgang in den Papierkorb legen?</DialogTitle>
<DialogDescription>
„{titel}“ verschwindet aus den Listen. Aufgaben, Fristen und der Zeitstrahl bleiben
30 Tage erhalten und lassen sich im Papierkorb wiederherstellen.
</DialogDescription>
</DialogHeader>
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<form action={aktion}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<Button type="submit" variant="destructive" disabled={laeuft}>
{laeuft ? 'Verschiebt…' : 'In den Papierkorb'}
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,227 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { AlertCircle } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { vorgangAendern, vorgangAnlegen } from '@/server/actions/vorgaenge'
import {
KATEGORIE_BESCHRIFTUNG,
PRIORITAET_BESCHRIFTUNG,
VORGANG_KATEGORIEN,
VORGANG_STATUS,
type Formularzustand,
} from '@/lib/validierung/vorgaenge'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
/** Date -> "2026-08-22" für <input type="date">. */
function alsFeldwert(datum: Date | null | undefined): string {
if (!datum) return ''
return datum.toISOString().slice(0, 10)
}
export type VorgangsFormularWerte = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
prioritaet: number
beschreibung: string | null
gegenstelle: string | null
aktenzeichen: string | null
verantwortlich: string | null
naechsterSchritt: string | null
faelligAm: Date | null
letzterKontaktAm: Date | null
genehmigungspflichtig: boolean
}
export function VorgangsFormular({
modus,
betreuteId,
vorgangId,
vorlageSchluessel,
werte,
abbrechenHref,
}: {
modus: 'anlegen' | 'aendern'
betreuteId: string
vorgangId?: string
vorlageSchluessel?: string | null
werte: VorgangsFormularWerte
abbrechenHref: string
}) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
{},
)
return (
<form action={aktion} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{vorgangId && <input type="hidden" name="vorgangId" value={vorgangId} />}
{modus === 'anlegen' && (
<input type="hidden" name="vorlageSchluessel" value={vorlageSchluessel ?? ''} />
)}
<Feldgruppe titel="Worum geht es?">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="So, dass du den Vorgang in einem Jahr wiedererkennst."
fehler={zustand.felder?.titel}
pflicht
className="sm:col-span-2"
>
<Input name="titel" defaultValue={werte.titel} required maxLength={200} />
</Feld>
<Feld id="kategorie" beschriftung="Kategorie" fehler={zustand.felder?.kategorie} pflicht>
<Select name="kategorie" defaultValue={werte.kategorie}>
{VORGANG_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VORGANG_STATUS.map((wert) => (
<option key={wert} value={wert}>
{VORGANG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="prioritaet"
beschriftung="Priorität"
erklaerung="Hoch heißt: das treibt gerade."
fehler={zustand.felder?.prioritaet}
>
<Select name="prioritaet" defaultValue={String(werte.prioritaet)}>
{[1, 2, 3].map((wert) => (
<option key={wert} value={wert}>
{PRIORITAET_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="verantwortlich" beschriftung="Verantwortlich" fehler={zustand.felder?.verantwortlich}>
<Input name="verantwortlich" defaultValue={werte.verantwortlich ?? ''} maxLength={120} />
</Feld>
<Feld
id="beschreibung"
beschriftung="Beschreibung"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.beschreibung}
className="sm:col-span-2"
>
<Textarea name="beschreibung" defaultValue={werte.beschreibung ?? ''} rows={5} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Wer ist am Zug?"
beschreibung="Behörde, Aktenzeichen und der letzte Kontakt - daraus entsteht später die Sachstandsanfrage."
>
<Feld id="gegenstelle" beschriftung="Gegenstelle" fehler={zustand.felder?.gegenstelle}>
<Input
name="gegenstelle"
defaultValue={werte.gegenstelle ?? ''}
maxLength={150}
placeholder="z. B. Bezirk Oberfranken"
/>
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen"
erklaerung="Das Zeichen, unter dem die Gegenstelle den Vorgang führt."
fehler={zustand.felder?.aktenzeichen}
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen ?? ''} maxLength={80} />
</Feld>
<Feld
id="naechsterSchritt"
beschriftung="Nächster Schritt"
erklaerung="Der eine konkrete Handgriff, der als Nächstes ansteht."
fehler={zustand.felder?.naechsterSchritt}
className="sm:col-span-2"
>
<Input
name="naechsterSchritt"
defaultValue={werte.naechsterSchritt ?? ''}
maxLength={300}
/>
</Feld>
<Feld id="faelligAm" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" defaultValue={alsFeldwert(werte.faelligAm)} />
</Feld>
<Feld
id="letzterKontaktAm"
beschriftung="Letzter Kontakt am"
fehler={zustand.felder?.letzterKontaktAm}
>
<Input
name="letzterKontaktAm"
type="date"
defaultValue={alsFeldwert(werte.letzterKontaktAm)}
/>
</Feld>
</Feldgruppe>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Checkbox
id="genehmigungspflichtig"
name="genehmigungspflichtig"
defaultChecked={werte.genehmigungspflichtig}
className="mt-1"
/>
<label htmlFor="genehmigungspflichtig" className="text-base font-medium">
Dieser Vorgang braucht eine Genehmigung des Betreuungsgerichts
</label>
</div>
<p className="text-sm text-muted-foreground">{GENEHMIGUNG_WARNUNG}</p>
<Rechtshinweis rechtsgrundlage="§ 1858 BGB" kurz />
</div>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : modus === 'anlegen' ? 'Vorgang anlegen' : 'Speichern'}
</Button>
<Button type="button" variant="ghost" asChild>
<Link href={abbrechenHref}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
@@ -0,0 +1,79 @@
import Link from 'next/link'
import { FilePlus2, ListChecks, Scale } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { VORLAGEN } from '@/lib/vorgaenge/vorlagen'
import { KATEGORIE_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
/**
* Vorlagenauswahl als Kachelliste.
*
* Jede Kachel sagt vorab, was sie mitbringt: wie viele Aufgaben, ob eine
* gerichtliche Genehmigung nötig ist und auf welcher Vorschrift das beruht.
* Wer erst nach dem Anlegen merkt, dass ein Genehmigungsverfahren daran
* hängt, hat unter Umständen schon etwas unterschrieben.
*/
export function VorlagenKacheln() {
const genehmigungspflichtige = VORLAGEN.some((v) => v.genehmigungspflichtig)
return (
<div className="space-y-6">
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{VORLAGEN.map((vorlage) => (
<li key={vorlage.schluessel}>
<Card className="h-full transition-colors hover:border-primary">
<CardContent className="flex h-full flex-col gap-3 p-5">
<Link
href={`/vorgaenge/neu?vorlage=${vorlage.schluessel}`}
className="text-base font-semibold hover:underline"
>
{vorlage.titel}
</Link>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorlage.kategorie]}</Badge>
<Badge variant="secondary">
<ListChecks className="size-3" aria-hidden="true" />
{vorlage.aufgaben.length} Aufgaben
</Badge>
{vorlage.genehmigungspflichtig && (
<Badge variant="gelb">
<Scale className="size-3" aria-hidden="true" />
Genehmigung nötig
</Badge>
)}
</div>
<p className="flex-1 text-sm text-muted-foreground">{vorlage.beschreibung}</p>
{vorlage.rechtsgrundlage && (
<p className="text-xs font-medium text-muted-foreground">
{vorlage.rechtsgrundlage}
</p>
)}
</CardContent>
</Card>
</li>
))}
<li>
<Card className="h-full border-dashed transition-colors hover:border-primary">
<CardContent className="flex h-full flex-col gap-3 p-5">
<Link href="/vorgaenge/neu?leer=1" className="text-base font-semibold hover:underline">
Leerer Vorgang
</Link>
<p className="flex-1 text-sm text-muted-foreground">
Ohne vorbefüllte Aufgaben. Für alles, wofür es (noch) keine Vorlage gibt -
Aufgaben lassen sich jederzeit ergänzen.
</p>
<FilePlus2 className="size-5 text-muted-foreground" aria-hidden="true" />
</CardContent>
</Card>
</li>
</ul>
{genehmigungspflichtige && <Rechtshinweis rechtsgrundlage="§ 1858 BGB" />}
</div>
)
}
@@ -0,0 +1,110 @@
import Link from 'next/link'
import {
Banknote,
CalendarClock,
CheckCircle2,
FileText,
FolderPlus,
MessageSquare,
Scale,
Shuffle,
} from 'lucide-react'
import type { Zeitstrahlart, Zeitstrahleintrag } from '@/lib/vorgaenge/zeitstrahl'
import { cn, formatCent, formatDatum } from '@/lib/utils'
/**
* Der Zeitstrahl einer Fallakte, neueste Ereignisse zuerst.
*
* Er beantwortet die Frage, die im Gespräch mit Gericht oder Bezirk immer
* zuerst kommt: "Wann haben Sie das geschickt?" Alles, was zum Vorgang
* gehört, steht in einer Spalte - Dokumente, Gespräche, Fristen, Zahlungen
* und die Stufen des Genehmigungsverfahrens.
*/
const SYMBOL: Record<Zeitstrahlart, React.ComponentType<{ className?: string }>> = {
ANGELEGT: FolderPlus,
DOKUMENT: FileText,
KONTAKT: MessageSquare,
FRIST: CalendarClock,
BUCHUNG: Banknote,
GENEHMIGUNG: Scale,
STATUS: Shuffle,
ERLEDIGT: CheckCircle2,
}
const ARTBESCHRIFTUNG: Record<Zeitstrahlart, string> = {
ANGELEGT: 'Anlage',
DOKUMENT: 'Dokument',
KONTAKT: 'Kontakt',
FRIST: 'Frist',
BUCHUNG: 'Buchung',
GENEHMIGUNG: 'Genehmigung',
STATUS: 'Status',
ERLEDIGT: 'Erledigt',
}
export function ZeitstrahlAnzeige({ eintraege }: { eintraege: Zeitstrahleintrag[] }) {
if (eintraege.length === 0) {
return (
<p className="rounded-md border border-dashed px-4 py-6 text-sm text-muted-foreground">
Hier ist noch nichts passiert. Sobald ein Dokument, ein Gespräch oder eine Zahlung zu
diesem Vorgang gehört, erscheint es hier.
</p>
)
}
return (
<ol className="space-y-0">
{eintraege.map((eintrag, index) => {
const Symbol = SYMBOL[eintrag.art]
const letzter = index === eintraege.length - 1
return (
<li key={eintrag.id} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className="flex size-8 shrink-0 items-center justify-center rounded-full border bg-muted text-muted-foreground"
aria-hidden="true"
>
<Symbol className="size-4" />
</span>
{!letzter && <span className="w-0.5 flex-1 bg-border" aria-hidden="true" />}
</div>
<div className={cn('min-w-0 flex-1 pb-6', letzter && 'pb-0')}>
<p className="text-sm tabular-nums text-muted-foreground">
<span className="sr-only">{ARTBESCHRIFTUNG[eintrag.art]} am </span>
{formatDatum(eintrag.datum)}
</p>
<p className="font-medium">
{eintrag.href ? (
<Link href={eintrag.href} className="hover:underline">
{eintrag.titel}
</Link>
) : (
eintrag.titel
)}
</p>
{eintrag.detail && (
<p className="text-sm text-muted-foreground">{eintrag.detail}</p>
)}
{eintrag.betragCent !== null && (
<p
className={cn(
'text-sm font-medium tabular-nums',
eintrag.betragCent < 0 ? 'text-ampel-rot' : 'text-ampel-gruen',
)}
>
{formatCent(eintrag.betragCent)}
</p>
)}
</div>
</li>
)
})}
</ol>
)
}