docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
+53
View File
@@ -0,0 +1,53 @@
import type {
BescheidPruefStatus,
LeistungStatus,
NachforderungStatus,
SozialhilfeBewertung,
} from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { PRUEFSTATUS_BESCHRIFTUNG, PRUEFSTATUS_VARIANTE } from '@/lib/sozialhilfe/bescheide'
import {
LEISTUNG_STATUS_BESCHRIFTUNG,
LEISTUNG_STATUS_VARIANTE,
} from '@/lib/sozialhilfe/leistungskatalog'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_VARIANTE,
} from '@/lib/sozialhilfe/nachforderungen'
import { BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_VARIANTE } from '@/lib/sozialhilfe/schonvermoegen'
/**
* Statuskennzeichen des Moduls.
*
* Alle tragen Text, nicht nur Farbe - wer rot und grün nicht unterscheidet,
* soll trotzdem sehen, woran er ist.
*/
export function NachforderungAbzeichen({ status }: { status: NachforderungStatus }) {
return <Badge variant={NACHFORDERUNG_VARIANTE[status]}>{NACHFORDERUNG_BESCHRIFTUNG[status]}</Badge>
}
export function BescheidAbzeichen({ status }: { status: BescheidPruefStatus }) {
return <Badge variant={PRUEFSTATUS_VARIANTE[status]}>{PRUEFSTATUS_BESCHRIFTUNG[status]}</Badge>
}
export function BewertungAbzeichen({ bewertung }: { bewertung: SozialhilfeBewertung }) {
return <Badge variant={BEWERTUNG_VARIANTE[bewertung]}>{BEWERTUNG_BESCHRIFTUNG[bewertung]}</Badge>
}
export function LeistungAbzeichen({ status }: { status: LeistungStatus }) {
return (
<Badge variant={LEISTUNG_STATUS_VARIANTE[status]}>{LEISTUNG_STATUS_BESCHRIFTUNG[status]}</Badge>
)
}
/** Freie Ampel für berechnete Lagen (Fristen, Deckungslücke). */
export function AmpelAbzeichen({
variante,
text,
}: {
variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
text: string
}) {
return <Badge variant={variante}>{text}</Badge>
}
@@ -0,0 +1,168 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { antragSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type AntragWerte = {
id: string | null
traeger: string
leistungsart: string
aktenzeichen: string
sachbearbeiter: string
telefon: string
email: string
antragAm: string
eingangBeimTraegerAm: string
bewilligtVon: string
bewilligtBis: string
notizen: string
}
/**
* Die Antragsakte: wer bearbeitet den Fall, unter welchem Aktenzeichen,
* seit wann und für welchen Zeitraum bewilligt.
*
* Mehrere Anträge nebeneinander sind der Normalfall - Erstantrag,
* Weiterbewilligung, dazu vielleicht ein eigener Antrag auf Grundsicherung.
* Jeder bekommt seine eigene Akte mit eigenen Nachforderungen und
* Bescheiden.
*/
export function AntragFormular({ werte }: { werte: AntragWerte }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(antragSpeichern, {})
return (
<form action={aktion} className="space-y-6">
{werte.id && <input type="hidden" name="antragId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-6 pt-5">
<Feldgruppe
titel="Träger und Leistung"
beschreibung="In Bayern ist für Leistungen in stationären Einrichtungen der Bezirk zuständig, nicht der Landkreis."
>
<Feld
id="traeger"
beschriftung="Träger"
pflicht
fehler={zustand.felder?.traeger}
erklaerung="Wer entscheidet über den Antrag?"
>
<Input name="traeger" defaultValue={werte.traeger} required />
</Feld>
<Feld
id="leistungsart"
beschriftung="Leistungsart"
pflicht
fehler={zustand.felder?.leistungsart}
erklaerung="z.B. Hilfe zur Pflege nach dem 7. Kapitel SGB XII"
>
<Input name="leistungsart" defaultValue={werte.leistungsart} required />
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichen}
erklaerung="Steht auf jedem Schreiben des Trägers. Gehört in jede Rückmeldung."
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Sachbearbeitung"
beschreibung="Ein Anruf klärt oft schneller als drei Briefe. Deshalb gehört die Durchwahl hierher."
>
<Feld
id="sachbearbeiter"
beschriftung="Sachbearbeiter/in"
fehler={zustand.felder?.sachbearbeiter}
>
<Input name="sachbearbeiter" defaultValue={werte.sachbearbeiter} />
</Feld>
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
<Input name="telefon" type="tel" defaultValue={werte.telefon} />
</Feld>
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
<Input name="email" type="email" defaultValue={werte.email} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Verfahrensdaten"
beschreibung="Das Eingangsdatum beim Träger ist wichtig: Leistungen setzen ab Kenntnis des Trägers ein (§ 18 SGB XII), nicht erst ab der Bewilligung."
>
<Feld
id="antragAm"
beschriftung="Antrag gestellt am"
fehler={zustand.felder?.antragAm}
erklaerung="Datum auf deinem Antragsschreiben."
>
<Input name="antragAm" type="date" defaultValue={werte.antragAm} />
</Feld>
<Feld
id="eingangBeimTraegerAm"
beschriftung="Eingang beim Träger"
fehler={zustand.felder?.eingangBeimTraegerAm}
erklaerung="Ab wann wusste der Träger Bescheid? Eingangsstempel oder Sendebericht aufheben."
>
<Input
name="eingangBeimTraegerAm"
type="date"
defaultValue={werte.eingangBeimTraegerAm}
/>
</Feld>
<Feld
id="bewilligtVon"
beschriftung="Bewilligt von"
fehler={zustand.felder?.bewilligtVon}
>
<Input name="bewilligtVon" type="date" defaultValue={werte.bewilligtVon} />
</Feld>
<Feld
id="bewilligtBis"
beschriftung="Bewilligt bis"
fehler={zustand.felder?.bewilligtBis}
erklaerung="Vor dem Ablauf rechtzeitig die Weiterbewilligung beantragen — sonst reißt die Zahlung ab."
>
<Input name="bewilligtBis" type="date" defaultValue={werte.bewilligtBis} />
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was am Telefon besprochen wurde, gehört hierher."
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Antragsakte anlegen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={werte.id ? `/sozialhilfe/${werte.id}` : '/sozialhilfe'}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button, type ButtonProps } from '@/components/ui/button'
import type { Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Kleinteile, die in allen Formularen des Sozialhilfemoduls
* wiederkehren. Sie liegen hier zusammen, damit nicht jedes Formular
* seinen eigenen Absende-Knopf und seine eigene Meldung erfindet.
*/
/**
* Absende-Knopf, der während des Speicherns gesperrt ist. Ohne die Sperre
* entstehen bei langsamer Verbindung Dubletten - und eine doppelt
* angelegte Nachforderung fällt erst beim Bezirk auf.
*/
export function Absenden({
children = 'Speichern',
laedtText = 'Wird gespeichert …',
disabled,
...props
}: ButtonProps & { laedtText?: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" {...props} disabled={pending || disabled}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? laedtText : children}
</Button>
)
}
/** Rückmeldung einer Server Action - Fehler oder Erfolg. */
export function Meldung({ zustand }: { zustand: Formularzustand }) {
if (zustand.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)
}
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)
}
return null
}
/**
* Ein Knopf, der eine Aktion ohne Formularfelder auslöst und vorher
* nachfragt. Für Löschen und andere Schritte, die man nicht versehentlich
* gehen will.
*/
export function AktionMitRueckfrage({
aktion,
frage,
children,
variant = 'ghost',
size = 'sm',
}: {
aktion: () => Promise<void>
frage: string
children: React.ReactNode
variant?: ButtonProps['variant']
size?: ButtonProps['size']
}) {
return (
<form
action={aktion}
onSubmit={(ereignis) => {
if (!window.confirm(frage)) ereignis.preventDefault()
}}
>
<Absenden variant={variant} size={size} laedtText="Einen Moment …">
{children}
</Absenden>
</form>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import type { BescheidArt } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
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 { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BESCHEID_ART_BESCHRIFTUNG, BESCHEID_ART_ERKLAERUNG, BESCHEID_ARTEN } from '@/lib/sozialhilfe/bescheide'
import { BELEHRUNG_ERKLAERUNG } from '@/lib/sozialhilfe/rechtsbehelf'
import { bescheidSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
import { Info } from 'lucide-react'
export type BescheidWerte = {
id: string | null
art: BescheidArt
bescheidVom: string
zugegangenAm: string
zeitraumVon: string
zeitraumBis: string
betrag: string
rechtsbehelfsfristMonate: number
widerspruchEingelegtAm: string
notizen: string
}
export const LEERER_BESCHEID: BescheidWerte = {
id: null,
art: 'BEWILLIGUNG',
bescheidVom: '',
zugegangenAm: '',
zeitraumVon: '',
zeitraumBis: '',
betrag: '',
rechtsbehelfsfristMonate: 1,
widerspruchEingelegtAm: '',
notizen: '',
}
/**
* Bescheid erfassen.
*
* Das wichtigste Feld ist das Zugangsdatum, nicht das Datum auf dem
* Bescheid. Die Widerspruchsfrist läuft ab Bekanntgabe (§ 84 Abs. 1 SGG) -
* zwischen Ausstellung und Zugang liegen fast immer mehrere Tage, und wer
* vom falschen Tag aus rechnet, verschenkt sie.
*/
export function BescheidFormular({
antragId,
werte,
zugangsvorschlag,
}: {
antragId: string
werte: BescheidWerte
/** Vorschlag nach § 37 Abs. 2 SGB X, sobald ein Bescheiddatum steht. */
zugangsvorschlag?: { datum: string; hinweis: string } | null
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidSpeichern, {})
const [art, setArt] = useState<BescheidArt>(werte.art)
const [monate, setMonate] = useState(String(werte.rechtsbehelfsfristMonate))
const [zugegangenAm, setZugegangenAm] = useState(werte.zugegangenAm)
return (
<form action={aktion} className="space-y-6">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="bescheidId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-5 pt-5">
<Feld
id="art"
beschriftung="Art des Bescheids"
pflicht
fehler={zustand.felder?.art}
erklaerung={BESCHEID_ART_ERKLAERUNG[art]}
>
<Select name="art" value={art} onChange={(e) => setArt(e.target.value as BescheidArt)}>
{BESCHEID_ARTEN.map((a) => (
<option key={a} value={a}>
{BESCHEID_ART_BESCHRIFTUNG[a]}
</option>
))}
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bescheidVom"
beschriftung="Bescheid vom"
pflicht
fehler={zustand.felder?.bescheidVom}
erklaerung="Das Datum, das oben auf dem Schreiben steht."
>
<Input name="bescheidVom" type="date" defaultValue={werte.bescheidVom} required />
</Feld>
<Feld
id="zugegangenAm"
beschriftung="Zugegangen am"
fehler={zustand.felder?.zugegangenAm}
erklaerung="Maßgeblich für den Fristbeginn — nicht das Datum auf dem Bescheid. Hebe den Briefumschlag mit dem Poststempel auf."
>
<Input
name="zugegangenAm"
type="date"
value={zugegangenAm}
onChange={(e) => setZugegangenAm(e.target.value)}
/>
</Feld>
</div>
{zugangsvorschlag && zugegangenAm === '' && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertTitle>Zugangsvermutung</AlertTitle>
<AlertDescription className="space-y-2">
<p>{zugangsvorschlag.hinweis}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setZugegangenAm(zugangsvorschlag.datum)}
>
{zugangsvorschlag.datum.split('-').reverse().join('.')} übernehmen
</Button>
</AlertDescription>
</Alert>
)}
<Feld
id="rechtsbehelfsfristMonate"
beschriftung="Rechtsbehelfsbelehrung"
fehler={zustand.felder?.rechtsbehelfsfristMonate}
erklaerung={
BELEHRUNG_ERKLAERUNG[monate === '12' ? 'FEHLT_ODER_FEHLERHAFT' : 'ORDNUNGSGEMAESS']
}
>
<Select
name="rechtsbehelfsfristMonate"
value={monate}
onChange={(e) => setMonate(e.target.value)}
>
<option value="1">Vorhanden und richtig — ein Monat (§ 84 SGG)</option>
<option value="12">Fehlt oder ist fehlerhaft — ein Jahr (§ 66 SGG)</option>
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="zeitraumVon"
beschriftung="Zeitraum von"
fehler={zustand.felder?.zeitraumVon}
>
<Input name="zeitraumVon" type="date" defaultValue={werte.zeitraumVon} />
</Feld>
<Feld id="zeitraumBis" beschriftung="Zeitraum bis" fehler={zustand.felder?.zeitraumBis}>
<Input name="zeitraumBis" type="date" defaultValue={werte.zeitraumBis} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="Monatlicher oder einmaliger Betrag laut Bescheid, z.B. 1.234,56"
>
<Input name="betrag" inputMode="decimal" defaultValue={werte.betrag} />
</Feld>
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Nur ausfüllen, wenn der Widerspruch bereits raus ist."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={werte.widerspruchEingelegtAm}
/>
</Feld>
</div>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was ist auffällig? Welche Position stimmt nicht?"
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
{zugegangenAm !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" defaultChecked />
<span>
Frist „Widerspruch möglich bis“ im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Mit dem berechneten Fristende und Vorwarnung 21, 14, 7, 3 und 1 Tag vorher. Der
Rechenweg steht in der Beschreibung der Frist.
</span>
</span>
</label>
)}
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 66 SGG" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Bescheid erfassen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={`/sozialhilfe/${antragId}`}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
@@ -0,0 +1,200 @@
'use client'
import { useActionState, useState } from 'react'
import type { BescheidPruefStatus } from '@prisma/client'
import { Scale } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
PRUEFSTATUS,
PRUEFSTATUS_BESCHRIFTUNG,
PRUEFSTATUS_ERKLAERUNG,
} from '@/lib/sozialhilfe/bescheide'
import { bescheidStatusSetzen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die Prüfansicht eines Bescheids: Rechenweg der Widerspruchsfrist,
* Ampel und Statuswechsel.
*
* Der Rechenweg steht ausgeschrieben da, weil die Anwendung sich irren
* kann. Wer eine Frist verpasst, soll sehen, wo die Rechnung abgebogen
* ist - und nicht einer Zahl vertrauen müssen, die aus dem Nichts kommt.
*/
export type Rechenweg = {
berechenbar: boolean
widerspruchMoeglichBis: string | null
rechnerischesEnde: string | null
verschoben: boolean
tageVerbleibend: number | null
abgelaufen: boolean
ampel: { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string }
schritte: { titel: string; text: string; rechtsgrundlage: string }[]
hinweise: string[]
}
function alsAnzeige(iso: string | null): string {
return iso ? iso.split('-').reverse().join('.') : '–'
}
export function BescheidPruefung({
bescheidId,
pruefStatus,
widerspruchEingelegtAm,
rechenweg,
darfSchreiben,
}: {
bescheidId: string
pruefStatus: BescheidPruefStatus
widerspruchEingelegtAm: string
rechenweg: Rechenweg
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidStatusSetzen, {})
const [status, setStatus] = useState<BescheidPruefStatus>(pruefStatus)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Widerspruchsfrist</CardTitle>
<CardDescription>
Berechnet aus dem Zugangsdatum. Die Anwendung rechnet — entscheiden tun der Träger
und im Streitfall das Sozialgericht.
</CardDescription>
</div>
<Badge variant={rechenweg.ampel.variante}>{rechenweg.ampel.text}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{rechenweg.berechenbar ? (
<>
<div className="rounded-lg border bg-muted/40 p-4">
<p className="text-sm text-muted-foreground">Widerspruch möglich bis</p>
<p className="text-2xl font-semibold tabular-nums">
{alsAnzeige(rechenweg.widerspruchMoeglichBis)}
</p>
{rechenweg.verschoben && (
<p className="mt-1 text-sm text-muted-foreground">
Rechnerisch wäre es der {alsAnzeige(rechenweg.rechnerischesEnde)} gewesen —
verschoben auf den nächsten Werktag.
</p>
)}
</div>
<ol className="space-y-3">
{rechenweg.schritte.map((schritt, i) => (
<li key={i} className="flex gap-3">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
aria-hidden="true"
>
{i + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{schritt.titel}</p>
<p className="text-sm text-muted-foreground">{schritt.text}</p>
<p className="mt-0.5 text-xs font-medium text-muted-foreground">
{schritt.rechtsgrundlage}
</p>
</div>
</li>
))}
</ol>
</>
) : (
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>Frist noch nicht berechenbar</AlertTitle>
<AlertDescription>
Ohne Zugangsdatum lässt sich nichts rechnen. Trag oben ein, wann der Bescheid im
Briefkasten lag.
</AlertDescription>
</Alert>
)}
{rechenweg.hinweise.map((hinweis, i) => (
<Alert key={i} variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>{hinweis}</AlertDescription>
</Alert>
))}
<Rechtshinweis rechtsgrundlage="§ 84 SGG" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Prüfstand</CardTitle>
<CardDescription>{PRUEFSTATUS_ERKLAERUNG[pruefStatus]}</CardDescription>
</div>
<BescheidAbzeichen status={pruefStatus} />
</div>
</CardHeader>
<CardContent>
{darfSchreiben ? (
<form action={aktion} className="space-y-4">
<input type="hidden" name="bescheidId" value={bescheidId} />
<Meldung zustand={zustand} />
<Feld
id="pruefStatus"
beschriftung="Neuer Stand"
fehler={zustand.felder?.pruefStatus}
erklaerung={PRUEFSTATUS_ERKLAERUNG[status]}
>
<Select
name="pruefStatus"
value={status}
onChange={(e) => setStatus(e.target.value as BescheidPruefStatus)}
>
{PRUEFSTATUS.map((s) => (
<option key={s} value={s}>
{PRUEFSTATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
{status === 'WIDERSPRUCH_EINGELEGT' && (
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
pflicht
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Der Tag, an dem der Widerspruch beim Träger eingegangen ist — nicht der Tag, an dem du ihn geschrieben hast."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={widerspruchEingelegtAm}
/>
</Feld>
)}
<Absenden variant="outline">Stand übernehmen</Absenden>
</form>
) : (
<p className="text-sm text-muted-foreground">
Zum Ändern des Prüfstands fehlt dir die Schreibberechtigung.
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,180 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung } from '@prisma/client'
import { BookOpen, ChevronDown } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Argumentationshilfe } from '@/lib/sozialhilfe/schonvermoegen'
import { ALLE_BEWERTUNGEN, BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { bewertungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Bewertung eines Vermögenspostens oder Vertrags im Sozialhilfeverfahren.
*
* Die Argumentationshilfen sind Textbausteine zum Übernehmen, keine
* Automatik: Die Anwendung setzt nie selbst eine Bewertung. Sie schlägt
* eine Richtung vor, liefert die Fundstelle und den Formulierungsvorschlag
* - eingeordnet wird von Hand, und über die Anrechnung entscheidet
* ohnehin der Träger.
*/
export function BewertungFormular({
herkunft,
id,
bezeichnung,
bewertung,
begruendung,
hilfen,
darfSchreiben,
beimSpeichern,
}: {
herkunft: 'VERMOEGEN' | 'VERTRAG'
id: string
bezeichnung: string
bewertung: SozialhilfeBewertung
begruendung: string
hilfen: Argumentationshilfe[]
darfSchreiben: boolean
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await bewertungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [auswahl, setAuswahl] = useState<SozialhilfeBewertung>(bewertung)
const [text, setText] = useState(begruendung)
const [offeneHilfe, setOffeneHilfe] = useState<string | null>(null)
if (!darfSchreiben) {
return (
<div className="space-y-2">
<p className="text-sm">
<span className="font-medium">{BEWERTUNG_BESCHRIFTUNG[bewertung]}</span>
</p>
{begruendung ? (
<p className="whitespace-pre-line text-sm text-muted-foreground">{begruendung}</p>
) : (
<p className="text-sm text-muted-foreground">Keine Begründung hinterlegt.</p>
)}
</div>
)
}
const uebernehmen = (hilfe: Argumentationshilfe) => {
setText((vorher) => (vorher.trim() === '' ? hilfe.textbaustein : `${vorher}\n\n${hilfe.textbaustein}`))
setAuswahl((vorher) => (vorher === 'UNGEPRUEFT' ? hilfe.vorschlag : vorher))
}
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="herkunft" value={herkunft} />
<input type="hidden" name="id" value={id} />
<Meldung zustand={zustand} />
<Feld
id={`bewertung-${id}`}
beschriftung={`Einschätzung zu „${bezeichnung}“`}
fehler={zustand.felder?.bewertung}
erklaerung={BEWERTUNG_ERKLAERUNG[auswahl]}
>
<Select
name="bewertung"
value={auswahl}
onChange={(e) => setAuswahl(e.target.value as SozialhilfeBewertung)}
>
{ALLE_BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id={`begruendung-${id}`}
beschriftung="Begründung"
fehler={zustand.felder?.begruendung}
erklaerung="Wird verschlüsselt gespeichert. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter — und in einem halben Jahr weißt du selbst nicht mehr, warum."
>
<Textarea
name="begruendung"
value={text}
onChange={(e) => setText(e.target.value)}
rows={8}
/>
</Feld>
{hilfen.length > 0 && (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<BookOpen className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">Argumentationshilfen</span>
</div>
<ul className="divide-y">
{hilfen.map((hilfe) => (
<li key={hilfe.schluessel} className="px-4 py-3">
<button
type="button"
onClick={() =>
setOffeneHilfe((v) => (v === hilfe.schluessel ? null : hilfe.schluessel))
}
aria-expanded={offeneHilfe === hilfe.schluessel}
className="flex w-full items-start justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block text-sm font-medium">{hilfe.titel}</span>
<span className="block text-sm text-muted-foreground">{hilfe.kurz}</span>
<span className="mt-0.5 block text-xs font-medium text-muted-foreground">
{hilfe.rechtsgrundlage}
</span>
</span>
<ChevronDown
className={`mt-1 size-4 shrink-0 transition-transform ${
offeneHilfe === hilfe.schluessel ? 'rotate-180' : ''
}`}
aria-hidden="true"
/>
</button>
{offeneHilfe === hilfe.schluessel && (
<div className="mt-3 space-y-3">
<div className="rounded-md border border-ampel-orange/40 bg-ampel-orange/10 p-3">
<p className="text-xs font-semibold uppercase tracking-wide">
Worauf zu achten ist
</p>
<p className="mt-1 text-sm">{hilfe.worauf_achten}</p>
</div>
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-3 font-sans text-sm">
{hilfe.textbaustein}
</pre>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => uebernehmen(hilfe)}
>
In die Begründung übernehmen
</Button>
</div>
)}
</li>
))}
</ul>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" />
<Absenden>Bewertung speichern</Absenden>
</form>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useActionState, useState } from 'react'
import { ListChecks } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Checklistenposition } from '@/lib/sozialhilfe/checkliste'
import { checklisteUebernehmen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die vorbefüllte Checkliste der Standardunterlagen.
*
* Zu jeder Position steht, warum der Träger sie will. Das ist der eigentliche
* Wert dieser Liste: Wer den Zweck kennt, beschafft das Richtige beim ersten
* Mal - und erkennt, wenn eine Nachforderung über das Nötige hinausgeht.
*/
export function ChecklistePanel({
antragId,
offen,
bereitsUebernommen,
}: {
antragId: string
offen: Checklistenposition[]
bereitsUebernommen: number
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checklisteUebernehmen, {})
const [ausgewaehlt, setAusgewaehlt] = useState<Set<string>>(new Set())
const umschalten = (schluessel: string) => {
setAusgewaehlt((vorher) => {
const neu = new Set(vorher)
if (neu.has(schluessel)) neu.delete(schluessel)
else neu.add(schluessel)
return neu
})
}
const alleWaehlen = () => {
setAusgewaehlt((vorher) =>
vorher.size === offen.length ? new Set() : new Set(offen.map((p) => p.schluessel)),
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListChecks className="size-5" aria-hidden="true" />
Standardunterlagen
</CardTitle>
<CardDescription>
Diesen Stapel fordert der Bezirk bei einem Antrag auf Hilfe zur Pflege fast immer an. Wer
ihn vorher beschafft, spart Wochen.{' '}
{bereitsUebernommen > 0 && (
<>
{bereitsUebernommen} von {bereitsUebernommen + offen.length} stehen schon im Tracker.
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{offen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Alle Standardunterlagen stehen bereits im Tracker. Weitere Anforderungen des Trägers
trägst du dort einzeln nach.
</p>
) : (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
<Meldung zustand={zustand} />
<button
type="button"
onClick={alleWaehlen}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{ausgewaehlt.size === offen.length ? 'Auswahl aufheben' : 'Alle auswählen'}
</button>
<ul className="max-h-[28rem] space-y-2 overflow-y-auto rounded-lg border p-2">
{offen.map((position) => (
<li key={position.schluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-accent/50">
<Checkbox
name="schluessel"
value={position.schluessel}
checked={ausgewaehlt.has(position.schluessel)}
onChange={() => umschalten(position.schluessel)}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium">{position.bezeichnung}</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
{position.erlaeuterung}
</span>
{position.rechtsgrundlage && (
<span className="mt-1 block text-xs font-medium text-muted-foreground">
{position.rechtsgrundlage}
</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="checkliste-angefordertAm"
beschriftung="Angefordert am"
erklaerung="Datum des Anforderungsschreibens — gilt für alle ausgewählten Positionen."
>
<Input name="angefordertAm" type="date" />
</Feld>
<Feld
id="checkliste-fristBis"
beschriftung="Frist des Trägers"
erklaerung="Optional. Kannst du später je Zeile anpassen."
>
<Input name="fristBis" type="date" />
</Feld>
</div>
<Absenden disabled={ausgewaehlt.size === 0}>
{ausgewaehlt.size === 0
? 'Nichts ausgewählt'
: `${ausgewaehlt.size} ${
ausgewaehlt.size === 1 ? 'Unterlage' : 'Unterlagen'
} übernehmen`}
</Absenden>
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</form>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,326 @@
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
vorschlagPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/** Pauschale der Pflegekasse zum hinterlegten Pflegegrad, als "1319,00". */
vorschlagPflegekasse: { betrag: string; text: string } | null
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [pflegekasse, setPflegekasse] = useState(werte.pflegekasse)
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse"
fehler={zustand.felder?.pflegekasse}
erklaerung={
vorschlagPflegekasse ? (
<>
{vorschlagPflegekasse.text}{' '}
<button
type="button"
onClick={() => setPflegekasse(vorschlagPflegekasse.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
) : (
'Pauschale nach § 43 SGB XI. Hinterlege den Pflegegrad in den Stammdaten, dann steht hier ein Vorschlag.'
)
}
>
<Input
name="pflegekasse"
inputMode="decimal"
value={pflegekasse}
onChange={(e) => setPflegekasse(e.target.value)}
/>
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
@@ -0,0 +1,85 @@
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCent } from '@/lib/utils'
import { monatsname } from '@/lib/sozialhilfe/heimkosten'
/**
* Jahresverlauf der Gegenüberstellung.
*
* Server Component: Die Beträge bleiben BigInt bis zur Anzeige, es geht
* nichts über die Grenze zum Browser. Die Tabelle scrollt in ihrem eigenen
* Rahmen - am Handy darf nie die ganze Seite seitlich wandern.
*/
export type MonatsZeile = {
jahr: number
monat: number
heimkostenCent: bigint
gesamtbedarfCent: bigint
deckungCent: bigint
deckungslueckeCent: bigint
ueberdeckungCent: bigint
abwesenheitstage: number
erfasst: boolean
}
export function HeimkostenTabelle({ zeilen }: { zeilen: MonatsZeile[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Monat</TableHead>
<TableHead className="text-right">Heimkosten</TableHead>
<TableHead className="text-right">Gesamtbedarf</TableHead>
<TableHead className="text-right">Deckung</TableHead>
<TableHead className="text-right">Ergebnis</TableHead>
<TableHead className="text-right">Abwesend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<TableRow key={`${zeile.jahr}-${zeile.monat}`}>
<TableCell className="font-medium">
{monatsname(zeile.monat)}
{!zeile.erfasst && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
nicht erfasst
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.heimkostenCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.gesamtbedarfCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.deckungCent) : '–'}
</TableCell>
<TableCell className="text-right">
{!zeile.erfasst ? (
'–'
) : zeile.deckungslueckeCent > 0n ? (
<Badge variant="rot">Lücke {formatCent(zeile.deckungslueckeCent)}</Badge>
) : zeile.ueberdeckungCent > 0n ? (
<Badge variant="gruen">Überschuss {formatCent(zeile.ueberdeckungCent)}</Badge>
) : (
<Badge variant="gruen">gedeckt</Badge>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? zeile.abwesenheitstage : '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useActionState, useState } from 'react'
import type { LeistungStatus } from '@prisma/client'
import { CalendarClock, Plus } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { LeistungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import type { Katalogleistung } from '@/lib/sozialhilfe/leistungskatalog'
import { LEISTUNG_STATUS, LEISTUNG_STATUS_BESCHRIFTUNG } from '@/lib/sozialhilfe/leistungskatalog'
import { formatCent, formatDatum } from '@/lib/utils'
import { katalogUebernehmen, leistungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type LeistungZeile = {
id: string
katalogSchluessel: string | null
bezeichnung: string
traeger: string
rechtsgrundlage: string
status: LeistungStatus
beantragtAm: string | null
bewilligtAm: string | null
gueltigBis: string | null
betragCent: number | null
jaehrlichNeuBeantragen: boolean
notizen: string
/** Aus dem Katalog nachgeschlagen, für den Ausklapp-Bereich. */
katalog: Katalogleistung | null
}
/**
* Katalog weiterer Leistungen mit pflegbarem Status.
*
* Der Katalog steht daneben, damit man nicht raten muss, was man für den
* Antrag braucht. Die jährlich zu wiederholenden Anträge tragen einen
* eigenen Hinweis - sie sind es, die untergehen.
*/
export function LeistungenListe({
leistungen,
offeneKatalogeintraege,
darfSchreiben,
}: {
leistungen: LeistungZeile[]
offeneKatalogeintraege: Katalogleistung[]
darfSchreiben: boolean
}) {
const [katalogZustand, katalogAktion] = useActionState<Formularzustand, FormData>(
katalogUebernehmen,
{},
)
const [offen, setOffen] = useState<string | null>(null)
const jaehrlich = leistungen.filter(
(l) => l.jaehrlichNeuBeantragen && l.status !== 'NICHT_ZUTREFFEND',
)
return (
<div className="space-y-6">
{jaehrlich.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertDescription>
<span className="font-medium">Jährlich neu zu beantragen: </span>
{jaehrlich.map((l) => l.bezeichnung).join(', ')}. Das bayerische Landespflegegeld
verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12. gestellt ist — leg dir dafür
eine Frist an.
</AlertDescription>
</Alert>
)}
{offeneKatalogeintraege.length > 0 && darfSchreiben && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="size-5" aria-hidden="true" />
Aus dem Katalog übernehmen
</CardTitle>
<CardDescription>
Leistungen, die noch nicht in der Liste stehen. Übernommen wird mit Träger,
Rechtsgrundlage und dem Hinweis, was man dafür braucht.
</CardDescription>
</CardHeader>
<CardContent>
<form action={katalogAktion} className="space-y-4">
<Meldung zustand={katalogZustand} />
<ul className="space-y-2">
{offeneKatalogeintraege.map((k) => (
<li key={k.katalogSchluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/50">
<Checkbox name="schluessel" value={k.katalogSchluessel} className="mt-1" />
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{k.bezeichnung}</span>
{k.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich</Badge>}
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">{k.kurz}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{k.traeger} · {k.rechtsgrundlage}
</span>
</span>
</label>
</li>
))}
</ul>
<Absenden variant="outline">Ausgewählte übernehmen</Absenden>
</form>
</CardContent>
</Card>
)}
<ul className="space-y-3">
{leistungen.map((l) => (
<li key={l.id}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{l.bezeichnung}</span>
<LeistungAbzeichen status={l.status} />
{l.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich neu</Badge>}
</div>
<p className="text-sm text-muted-foreground">
{l.traeger}
{l.rechtsgrundlage ? ` · ${l.rechtsgrundlage}` : ''}
</p>
<p className="text-sm">
{l.betragCent !== null && (
<span className="font-medium tabular-nums">{formatCent(l.betragCent)} </span>
)}
{l.beantragtAm && <>Beantragt am {formatDatum(l.beantragtAm)}. </>}
{l.bewilligtAm && <>Bewilligt am {formatDatum(l.bewilligtAm)}. </>}
{l.gueltigBis && <>Gültig bis {formatDatum(l.gueltigBis)}.</>}
</p>
</div>
{darfSchreiben && (
<Button
variant="outline"
size="sm"
onClick={() => setOffen((v) => (v === l.id ? null : l.id))}
>
{offen === l.id ? 'Zuklappen' : 'Bearbeiten'}
</Button>
)}
</div>
{l.katalog && (
<details className="rounded-md border bg-muted/30 p-3">
<summary className="cursor-pointer text-sm font-medium">
Voraussetzungen und benötigte Unterlagen
</summary>
<p className="mt-2 text-sm">{l.katalog.voraussetzung}</p>
{l.katalog.betragHinweis && (
<p className="mt-2 text-sm text-muted-foreground">{l.katalog.betragHinweis}</p>
)}
<p className="mt-2 text-sm font-medium">Dafür brauchst du:</p>
<ul className="mt-1 list-inside list-disc text-sm text-muted-foreground">
{l.katalog.benoetigt.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</details>
)}
{offen === l.id && darfSchreiben && (
<div className="border-t pt-4">
<LeistungFormular zeile={l} beimSpeichern={() => setOffen(null)} />
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
<Rechtshinweis rechtsgrundlage="SGB XII, SGB V, SGB IX, BayLPflGG" />
</div>
)
}
function LeistungFormular({
zeile,
beimSpeichern,
}: {
zeile: LeistungZeile
beimSpeichern: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await leistungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern()
return ergebnis
},
{},
)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="leistungId" value={zeile.id} />
<Meldung zustand={zustand} />
<Feld id={`bezeichnung-${zeile.id}`} beschriftung="Bezeichnung" pflicht fehler={zustand.felder?.bezeichnung}>
<Input name="bezeichnung" defaultValue={zeile.bezeichnung} required />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id={`traeger-${zeile.id}`} beschriftung="Träger" fehler={zustand.felder?.traeger}>
<Input name="traeger" defaultValue={zeile.traeger} />
</Feld>
<Feld
id={`rechtsgrundlage-${zeile.id}`}
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
>
<Input name="rechtsgrundlage" defaultValue={zeile.rechtsgrundlage} />
</Feld>
<Feld id={`status-${zeile.id}`} beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={zeile.status}>
{LEISTUNG_STATUS.map((s) => (
<option key={s} value={s}>
{LEISTUNG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`betrag-${zeile.id}`}
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="z.B. 1.000,00 für das Landespflegegeld."
>
<Input
name="betrag"
inputMode="decimal"
defaultValue={
zeile.betragCent === null
? ''
: `${Math.trunc(zeile.betragCent / 100)},${String(Math.abs(zeile.betragCent % 100)).padStart(2, '0')}`
}
/>
</Feld>
<Feld
id={`beantragtAm-${zeile.id}`}
beschriftung="Beantragt am"
fehler={zustand.felder?.beantragtAm}
>
<Input name="beantragtAm" type="date" defaultValue={zeile.beantragtAm ?? ''} />
</Feld>
<Feld
id={`bewilligtAm-${zeile.id}`}
beschriftung="Bewilligt am"
fehler={zustand.felder?.bewilligtAm}
>
<Input name="bewilligtAm" type="date" defaultValue={zeile.bewilligtAm ?? ''} />
</Feld>
<Feld
id={`gueltigBis-${zeile.id}`}
beschriftung="Gültig bis"
fehler={zustand.felder?.gueltigBis}
erklaerung="Danach ist ein neuer Nachweis fällig."
>
<Input name="gueltigBis" type="date" defaultValue={zeile.gueltigBis ?? ''} />
</Feld>
</div>
<label className="flex items-start gap-3 text-sm">
<Checkbox
name="jaehrlichNeuBeantragen"
defaultChecked={zeile.jaehrlichNeuBeantragen}
className="mt-0.5"
/>
<span>Muss jährlich neu beantragt werden</span>
</label>
<Feld id={`notizen-${zeile.id}`} beschriftung="Notizen" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={zeile.notizen} rows={3} />
</Feld>
<Absenden>Speichern</Absenden>
</form>
)
}
@@ -0,0 +1,184 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { Feld } from '@/components/feld'
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 { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_ERKLAERUNG,
NACHFORDERUNG_STATUS,
} from '@/lib/sozialhilfe/nachforderungen'
import { nachforderungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type NachforderungWerte = {
id: string | null
bezeichnung: string
erlaeuterung: string
status: NachforderungStatus
angefordertAm: string
fristBis: string
uebersandtAm: string
begruendungNichtVorhanden: string
reihenfolge: number
ausCheckliste: boolean
}
export const LEERE_NACHFORDERUNG: NachforderungWerte = {
id: null,
bezeichnung: '',
erlaeuterung: '',
status: 'OFFEN',
angefordertAm: '',
fristBis: '',
uebersandtAm: '',
begruendungNichtVorhanden: '',
reihenfolge: 0,
ausCheckliste: false,
}
/**
* Eine Zeile des Trackers anlegen oder ändern.
*
* Das Begründungsfeld erscheint, sobald "Nicht vorhanden" gewählt ist.
* Es ist dann Pflicht: Eine unbeantwortete Zeile sieht beim Träger wie
* fehlende Mitwirkung aus (§ 60, § 66 SGB I), eine begründete Fehlanzeige
* dagegen ist eine Antwort.
*/
export function NachforderungFormular({
antragId,
werte,
beimSpeichern,
}: {
antragId: string
werte: NachforderungWerte
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await nachforderungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [status, setStatus] = useState<NachforderungStatus>(werte.status)
const [fristBis, setFristBis] = useState(werte.fristBis)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="nachforderungId" value={werte.id} />}
<input type="hidden" name="reihenfolge" value={werte.reihenfolge} />
<Meldung zustand={zustand} />
<Feld
id={`bezeichnung-${werte.id ?? 'neu'}`}
beschriftung="Welche Unterlage?"
pflicht
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required />
</Feld>
<Feld
id={`erlaeuterung-${werte.id ?? 'neu'}`}
beschriftung="Erläuterung"
fehler={zustand.felder?.erlaeuterung}
erklaerung="Wozu braucht der Träger das? Wer den Zweck notiert, kann Rückfragen vorwegnehmen."
>
<Textarea name="erlaeuterung" defaultValue={werte.erlaeuterung} rows={3} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id={`status-${werte.id ?? 'neu'}`}
beschriftung="Status"
fehler={zustand.felder?.status}
erklaerung={NACHFORDERUNG_ERKLAERUNG[status]}
>
<Select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value as NachforderungStatus)}
>
{NACHFORDERUNG_STATUS.map((s) => (
<option key={s} value={s}>
{NACHFORDERUNG_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`angefordertAm-${werte.id ?? 'neu'}`}
beschriftung="Angefordert am"
fehler={zustand.felder?.angefordertAm}
erklaerung="Datum des Schreibens vom Träger."
>
<Input name="angefordertAm" type="date" defaultValue={werte.angefordertAm} />
</Feld>
<Feld
id={`fristBis-${werte.id ?? 'neu'}`}
beschriftung="Frist des Trägers"
fehler={zustand.felder?.fristBis}
>
<Input
name="fristBis"
type="date"
value={fristBis}
onChange={(e) => setFristBis(e.target.value)}
/>
</Feld>
<Feld
id={`uebersandtAm-${werte.id ?? 'neu'}`}
beschriftung="Übersandt am"
fehler={zustand.felder?.uebersandtAm}
erklaerung="Der Nachweis der Mitwirkung, falls der Träger später von einer Lücke ausgeht."
>
<Input name="uebersandtAm" type="date" defaultValue={werte.uebersandtAm} />
</Feld>
</div>
{status === 'NICHT_VORHANDEN' && (
<Feld
id={`begruendung-${werte.id ?? 'neu'}`}
beschriftung="Warum gibt es die Unterlage nicht?"
pflicht
fehler={zustand.felder?.begruendungNichtVorhanden}
erklaerung="Dieser Text geht mit ins Unterlagenpaket. Beispiel: „Ein Mietvertrag existiert nicht, die Wohnung wurde 1978 als Eigentum erworben.“"
>
<Textarea
name="begruendungNichtVorhanden"
defaultValue={werte.begruendungNichtVorhanden}
rows={3}
required
/>
</Feld>
)}
{fristBis !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" />
<span>
Frist im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Legt eine behördliche Frist zum {fristBis.split('-').reverse().join('.')} an, mit
Vorwarnung 14, 7, 3 und 1 Tag vorher.
</span>
</span>
</label>
)}
<Absenden>{werte.id ? 'Änderungen speichern' : 'Nachforderung aufnehmen'}</Absenden>
</form>
)
}
@@ -0,0 +1,301 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { CalendarClock, FileText, Pencil, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { AktionMitRueckfrage, Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { NachforderungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
LEERE_NACHFORDERUNG,
NachforderungFormular,
type NachforderungWerte,
} from '@/components/sozialhilfe/nachforderung-formular'
import { fristAmpel, trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
import { formatDatum } from '@/lib/utils'
import {
nachforderungLoeschen,
nachforderungStatusSetzen,
type Formularzustand,
} from '@/server/actions/sozialhilfe'
/**
* Der Nachforderungs-Tracker.
*
* Solange auch nur eine Zeile offen ist, ruht das Verfahren. Deshalb steht
* der Balken oben und die überfälligen Zeilen darüber - nicht als
* Statistik, sondern als Arbeitsliste.
*
* Alle Datumswerte kommen als ISO-Zeichenkette aus der Server Component;
* Date-Objekte und BigInt überstehen die Grenze zum Browser nicht.
*/
export type TrackerEintrag = {
id: string
bezeichnung: string
erlaeuterung: string | null
status: NachforderungStatus
angefordertAm: string | null
fristBis: string | null
uebersandtAm: string | null
begruendungNichtVorhanden: string | null
reihenfolge: number
ausCheckliste: boolean
anzahlDokumente: number
}
function alsDatum(iso: string | null): Date | null {
return iso ? new Date(`${iso}T00:00:00.000Z`) : null
}
export function NachforderungTracker({
antragId,
eintraege,
darfSchreiben,
}: {
antragId: string
eintraege: TrackerEintrag[]
darfSchreiben: boolean
}) {
const [neuOffen, setNeuOffen] = useState(false)
const fortschritt = trackerFortschritt(
eintraege.map((e) => ({
id: e.id,
bezeichnung: e.bezeichnung,
status: e.status,
fristBis: alsDatum(e.fristBis),
})),
)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Nachforderungen</CardTitle>
<CardDescription>
Jede vom Träger angeforderte Unterlage als eigene Zeile. Auch eine begründete
Fehlanzeige zählt als geliefert — sie ist eine Antwort.
</CardDescription>
</div>
{darfSchreiben && (
<Button variant="outline" size="sm" onClick={() => setNeuOffen((o) => !o)}>
<Plus aria-hidden="true" />
{neuOffen ? 'Abbrechen' : 'Nachforderung'}
</Button>
)}
</div>
<Fortschritt
className="pt-2"
wert={fortschritt.geliefert}
gesamt={fortschritt.gesamt}
beschriftung={fortschritt.beschriftung}
/>
</CardHeader>
<CardContent className="space-y-4">
{fortschritt.ueberfaellig.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertTitle>
{fortschritt.ueberfaellig.length}{' '}
{fortschritt.ueberfaellig.length === 1 ? 'Unterlage ist' : 'Unterlagen sind'}{' '}
überfällig
</AlertTitle>
<AlertDescription>
{fortschritt.ueberfaellig.map((z) => z.bezeichnung).join(', ')}. Wenn du sie nicht
beschaffen kannst, melde das dem Träger mit Begründung — Schweigen wird als fehlende
Mitwirkung gewertet (§ 66 SGB I).
</AlertDescription>
</Alert>
)}
{neuOffen && darfSchreiben && (
<div className="rounded-lg border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-semibold">Neue Nachforderung</h3>
<NachforderungFormular
antragId={antragId}
werte={{ ...LEERE_NACHFORDERUNG, reihenfolge: (eintraege.length + 1) * 10 }}
beimSpeichern={() => setNeuOffen(false)}
/>
</div>
)}
{eintraege.length === 0 ? (
<Leerzustand
titel="Noch keine Nachforderungen erfasst"
beschreibung="Übernimm die Standardunterlagen aus der Checkliste — dann steht der übliche Stapel des Bezirks schon hier, bevor er ihn anfordert."
icon={FileText}
/>
) : (
<ul className="divide-y rounded-lg border">
{eintraege.map((eintrag) => (
<TrackerZeile
key={eintrag.id}
antragId={antragId}
eintrag={eintrag}
darfSchreiben={darfSchreiben}
/>
))}
</ul>
)}
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</CardContent>
</Card>
)
}
function TrackerZeile({
antragId,
eintrag,
darfSchreiben,
}: {
antragId: string
eintrag: TrackerEintrag
darfSchreiben: boolean
}) {
const [offen, setOffen] = useState(false)
const [zustand, statusSetzen] = useActionState<Formularzustand, FormData>(
nachforderungStatusSetzen,
{},
)
const ampel = fristAmpel(alsDatum(eintrag.fristBis), eintrag.status)
return (
<li className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{eintrag.bezeichnung}</span>
<NachforderungAbzeichen status={eintrag.status} />
{eintrag.fristBis && <Badge variant={ampel.variante}>{ampel.text}</Badge>}
{eintrag.ausCheckliste && <Badge variant="outline">aus Checkliste</Badge>}
{eintrag.anzahlDokumente > 0 && (
<Badge variant="outline">
<FileText className="size-3" aria-hidden="true" />
{eintrag.anzahlDokumente}
</Badge>
)}
</div>
{eintrag.erlaeuterung && (
<p className="text-sm text-muted-foreground">{eintrag.erlaeuterung}</p>
)}
<p className="text-xs text-muted-foreground">
{eintrag.angefordertAm && <>Angefordert am {formatDatum(eintrag.angefordertAm)}. </>}
{eintrag.fristBis && <>Frist bis {formatDatum(eintrag.fristBis)}. </>}
{eintrag.uebersandtAm && <>Übersandt am {formatDatum(eintrag.uebersandtAm)}.</>}
</p>
{eintrag.status === 'NICHT_VORHANDEN' && eintrag.begruendungNichtVorhanden && (
<p className="rounded-md border border-border bg-muted/50 p-2 text-sm">
<span className="font-medium">Begründung für den Träger: </span>
{eintrag.begruendungNichtVorhanden}
</p>
)}
</div>
{darfSchreiben && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
{eintrag.status === 'OFFEN' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="IN_BESCHAFFUNG"
text="In Beschaffung"
/>
)}
{(eintrag.status === 'OFFEN' || eintrag.status === 'IN_BESCHAFFUNG') && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="UEBERSANDT"
text="Übersandt"
/>
)}
{eintrag.status === 'UEBERSANDT' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="ANERKANNT"
text="Anerkannt"
/>
)}
<Button variant="ghost" size="sm" onClick={() => setOffen((o) => !o)}>
<Pencil aria-hidden="true" />
<span className="sr-only sm:not-sr-only">{offen ? 'Zuklappen' : 'Bearbeiten'}</span>
</Button>
<AktionMitRueckfrage
aktion={nachforderungLoeschen.bind(null, eintrag.id)}
frage={`„${eintrag.bezeichnung}“ in den Papierkorb legen?`}
>
<Trash2 aria-hidden="true" />
<span className="sr-only">Löschen</span>
</AktionMitRueckfrage>
</div>
)}
</div>
<div className="mt-2">
<Meldung zustand={zustand} />
</div>
{offen && darfSchreiben && (
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<NachforderungFormular
antragId={antragId}
werte={{
id: eintrag.id,
bezeichnung: eintrag.bezeichnung,
erlaeuterung: eintrag.erlaeuterung ?? '',
status: eintrag.status,
angefordertAm: eintrag.angefordertAm ?? '',
fristBis: eintrag.fristBis ?? '',
uebersandtAm: eintrag.uebersandtAm ?? '',
begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden ?? '',
reihenfolge: eintrag.reihenfolge,
ausCheckliste: eintrag.ausCheckliste,
}}
beimSpeichern={() => setOffen(false)}
/>
</div>
)}
</li>
)
}
/** Ein-Klick-Statuswechsel für die Fälle, die keine weitere Angabe brauchen. */
function SchnellStatus({
aktion,
nachforderungId,
ziel,
text,
}: {
aktion: (formular: FormData) => void
nachforderungId: string
ziel: NachforderungStatus
text: string
}) {
return (
<form action={aktion}>
<input type="hidden" name="nachforderungId" value={nachforderungId} />
<input type="hidden" name="status" value={ziel} />
<Absenden variant="outline" size="sm" laedtText="…">
{text}
</Absenden>
</form>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState } from 'react'
import type { SozialhilfeBewertung, VermoegensArt, VertragArt } from '@prisma/client'
import { Coins, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { BewertungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import { BewertungFormular } from '@/components/sozialhilfe/bewertung-formular'
import {
ALLE_BEWERTUNGEN,
BEWERTUNG_BESCHRIFTUNG,
hilfenFuerVermoegensart,
hilfenFuerVertragsart,
} from '@/lib/sozialhilfe/schonvermoegen'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Liste der zu bewertenden Posten mit Filter.
*
* Beträge kommen als Number in Cent herüber - BigInt überlebt die Grenze
* zwischen Server und Browser nicht. Angezeigt wird über formatCent(),
* gerechnet wird auf dem Server.
*/
export type ListenPosten = {
id: string
bezeichnung: string
herkunft: 'VERMOEGEN' | 'VERTRAG'
art: string
artBeschriftung: string
bewertung: SozialhilfeBewertung
wertCent: number | null
wertStichtag: string | null
begruendung: string
zusatz: string | null
}
export function Vermoegensliste({
posten,
darfSchreiben,
}: {
posten: ListenPosten[]
darfSchreiben: boolean
}) {
const [filter, setFilter] = useState<SozialhilfeBewertung | 'ALLE'>('ALLE')
const [offen, setOffen] = useState<string | null>(null)
const sichtbar = filter === 'ALLE' ? posten : posten.filter((p) => p.bewertung === filter)
const zaehler = (b: SozialhilfeBewertung) => posten.filter((p) => p.bewertung === b).length
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Bewertung filtern">
<FilterKnopf aktiv={filter === 'ALLE'} onClick={() => setFilter('ALLE')}>
Alle ({posten.length})
</FilterKnopf>
{ALLE_BEWERTUNGEN.map((b) => (
<FilterKnopf key={b} aktiv={filter === b} onClick={() => setFilter(b)}>
{BEWERTUNG_BESCHRIFTUNG[b]} ({zaehler(b)})
</FilterKnopf>
))}
</div>
{sichtbar.length === 0 ? (
<Leerzustand
titel="Keine Posten in dieser Ansicht"
beschreibung={
posten.length === 0
? 'Vermögensposten und Verträge legst du in den Modulen „Vermögen & Kasse“ und „Verträge“ an. Hier bewertest du sie für das Sozialamt.'
: 'Wähle einen anderen Filter, um die übrigen Posten zu sehen.'
}
icon={Coins}
/>
) : (
<ul className="space-y-3">
{sichtbar.map((p) => (
<li key={`${p.herkunft}-${p.id}`}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{p.bezeichnung}</span>
<BewertungAbzeichen bewertung={p.bewertung} />
</div>
<p className="text-sm text-muted-foreground">
{p.artBeschriftung}
{p.zusatz ? ` · ${p.zusatz}` : ''}
</p>
<p className="text-sm">
<span className="font-medium tabular-nums">
{p.wertCent === null ? 'Wert noch nicht ermittelt' : formatCent(p.wertCent)}
</span>
{p.wertStichtag && (
<span className="text-muted-foreground">
{' '}
(Stand {formatDatum(p.wertStichtag)})
</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setOffen((v) => (v === `${p.herkunft}-${p.id}` ? null : `${p.herkunft}-${p.id}`))
}
>
<Pencil aria-hidden="true" />
{offen === `${p.herkunft}-${p.id}` ? 'Zuklappen' : 'Bewerten'}
</Button>
</div>
{p.begruendung && offen !== `${p.herkunft}-${p.id}` && (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{p.begruendung}
</p>
)}
{!p.begruendung && p.bewertung !== 'UNGEPRUEFT' && (
<p className="text-sm text-ampel-orange">
Bewertet, aber ohne Begründung. Im Widerspruchsverfahren zählt nur, was
begründet ist.
</p>
)}
{offen === `${p.herkunft}-${p.id}` && (
<div className="border-t pt-4">
<BewertungFormular
herkunft={p.herkunft}
id={p.id}
bezeichnung={p.bezeichnung}
bewertung={p.bewertung}
begruendung={p.begruendung}
darfSchreiben={darfSchreiben}
hilfen={
p.herkunft === 'VERMOEGEN'
? hilfenFuerVermoegensart(p.art as VermoegensArt)
: hilfenFuerVertragsart(p.art as VertragArt)
}
beimSpeichern={() => setOffen(null)}
/>
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
)
}
function FilterKnopf({
aktiv,
onClick,
children,
}: {
aktiv: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button variant={aktiv ? 'default' : 'outline'} size="sm" onClick={onClick} aria-pressed={aktiv}>
{children}
</Button>
)
}