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