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