feat: Immobilie, Versicherungen und Heim mit COPD-Verlauf

Etappe 6. Das leerstehende Haus, die Vertraege und der
Gesundheitsverlauf - die drei Bereiche, in denen fuer diesen Fall das
meiste Geld und das meiste Risiko liegt.

Leerstands-Checkliste mit zehn Punkten, jeder mit Begruendung. Der
wichtigste: den Gebaeudeversicherer ueber den Leerstand informieren -
sonst droht Leistungsfreiheit, und zwar genau dann, wenn es brennt oder
eine Leitung platzt. Dazu Verkehrssicherungspflicht, Frostschutz zum
Oktober, Kontrollgaenge, Zaehlerstaende, Schluesselverzeichnis.

Der Kuendigungsrechner nennt den spaetesten ABSENDETAG, nicht den
Fristablauf: eine Kuendigung wirkt mit dem Zugang, nicht mit dem
Abschicken. Drei Tage Postlaufpuffer sind einkalkuliert.

Der Kuendigungs-Assistent beantwortet die Genehmigungsfrage nach
§ 1850 BGB nicht - er stellt sie und verlangt den Gang zum
Rechtspfleger. Der Versandnachweis ist Pflichtfeld: ohne ihn kommt die
Kuendigung nicht durch die Pruefung.

Leistungszuschlag nach § 43c SGB XI mit Stand 01.01.2024
(15/30/50/75 Prozent nach Verweildauer, erst ab Pflegegrad 2). Die
Oberflaeche zeigt, ab wann die naechste Stufe greift - das ist bares
Geld und wird regelmaessig uebersehen.

COPD-Schnellerfassung fuer Exazerbation, Sauerstoff, Sturz,
Krankenhaus, Hilfsmittel und Impfungen, mit STIKO-Plan fuer COPD.
Beschreibungen verschluesselt. Die Jahresauswertung ueber Exazerbationen
und Krankenhaustage wandert in den Jahresbericht und begruendet eine
Hoeherstufung.

74 Tests.

Etappe: 6
Phase: Substanz
This commit is contained in:
Florian Wilde
2026-08-22 06:22:24 +02:00
parent d35e24f830
commit b69a12da8f
28 changed files with 4309 additions and 917 deletions
+1 -1
View File
@@ -195,7 +195,7 @@ export default async function ImmobilieDetail({ params }: { params: Promise<{ id
immobilieId={id}
darfSchreiben={schreiben}
status={immobilie.statusSozialhilfe}
begruendung={offen.statusBegruendung ?? null}
begruendung={offen.statusBegruendung}
/>
<Kostenkonto
+87
View File
@@ -0,0 +1,87 @@
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
HeimvertragFormular,
LEERER_HEIMVERTRAG,
} from '@/components/pflege/heimvertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 145000 wird zu "1450,00". */
function centAlsText(cent: bigint | null): string {
if (cent === null) return ''
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
return `${negativ ? '-' : ''}${betrag / 100n},${(betrag % 100n).toString().padStart(2, '0')}`
}
export default async function HeimvertragSeite() {
const benutzer = await erfordereBenutzer('/pflege/heimvertrag')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'GESUNDHEIT', 'lesen')
if (!darfSchreiben('GESUNDHEIT')) redirect('/pflege')
const heimvertrag = await db.heimvertrag.findUnique({ where: { betreuteId: aktive.id } })
const offen = heimvertrag ? oeffnen('Heimvertrag', heimvertrag) : null
return (
<div className="space-y-6">
<SeitenKopf
titel={heimvertrag ? 'Heimvertrag bearbeiten' : 'Heimvertrag erfassen'}
beschreibung="Die Sätze stehen im Vertrag und auf jeder Heimrechnung. Ändern sie sich, trag sie hier nach — die alten Werte bleiben in der monatlichen Gegenüberstellung erhalten."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Heim & Pflege', href: '/pflege' },
{ titel: 'Heimvertrag' },
]}
/>
<HeimvertragFormular
betreuteId={aktive.id}
werte={
heimvertrag
? {
einrichtung: heimvertrag.einrichtung,
traeger: heimvertrag.traeger ?? '',
strasse: heimvertrag.strasse ?? '',
plz: heimvertrag.plz ?? '',
ort: heimvertrag.ort ?? '',
vertragAm: heimvertrag.vertragAm ? alsIsoTag(heimvertrag.vertragAm) : '',
einzugAm: heimvertrag.einzugAm ? alsIsoTag(heimvertrag.einzugAm) : '',
auszugAm: heimvertrag.auszugAm ? alsIsoTag(heimvertrag.auszugAm) : '',
kuendigungsfristTage: heimvertrag.kuendigungsfristTage?.toString() ?? '',
pflegeEigenanteil: centAlsText(heimvertrag.pflegeEigenanteilCent),
unterkunftVerpflegung: centAlsText(heimvertrag.unterkunftVerpflegungCent),
investitionskosten: centAlsText(heimvertrag.investitionskostenCent),
ausbildungsumlage: centAlsText(heimvertrag.ausbildungsumlageCent),
notizen: offen?.notizen ?? '',
}
: {
...LEERER_HEIMVERTRAG,
einrichtung: aktive.heimName ?? '',
strasse: aktive.heimStrasse ?? '',
plz: aktive.heimPlz ?? '',
ort: aktive.heimOrt ?? '',
einzugAm: aktive.heimEinzugAm ? alsIsoTag(aktive.heimEinzugAm) : '',
}
}
/>
<Rechtshinweis rechtsgrundlage="§ 43c SGB XI, WBVG" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: Der pflegebedingte Eigenanteil ist der einzige Posten, auf den der
Leistungszuschlag nach § 43c SGB XI wirkt. Das Wohn- und Betreuungsvertragsgesetz
(WBVG) regelt, wie und wann gekündigt werden kann und wann die Einrichtung Entgelte
erhöhen darf — eine Erhöhung muss schriftlich angekündigt und begründet werden. Prüf
jede Ankündigung, statt sie stillschweigend hinzunehmen.
</p>
</div>
)
}
+252
View File
@@ -0,0 +1,252 @@
import Link from 'next/link'
import { Building, HeartPulse, Pencil, ScrollText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular'
import { AbwesenheitKarte } from '@/components/pflege/abwesenheit-karte'
import { Impfstatus } from '@/components/pflege/impfstatus'
import { LeistungszuschlagKarte } from '@/components/pflege/leistungszuschlag-karte'
import { PflegegradKarte } from '@/components/pflege/pflegegrad-karte'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag, plusMonate } from '@/lib/fristen/datum'
import { abwesenheitstageGesamt, abwesenheitstageJeJahr } from '@/lib/pflege/abwesenheit'
import { impfstatusBerechnen } from '@/lib/pflege/impfungen'
import { leistungszuschlagBerechnen } from '@/lib/pflege/leistungszuschlag'
import { HOEHERSTUFUNG_ABSTAND_MONATE } from '@/lib/pflege/konstanten'
import { verlaufAuswerten, verschlechterungErkannt } from '@/lib/pflege/verlauf'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function PflegeUebersicht() {
const benutzer = await erfordereBenutzer('/pflege')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'GESUNDHEIT', 'lesen')
const schreiben = darfSchreiben('GESUNDHEIT')
const [heimvertrag, ereignisse, pruefFrist] = await Promise.all([
db.heimvertrag.findUnique({ where: { betreuteId: aktive.id } }),
db.gesundheitsereignis.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
orderBy: { datum: 'desc' },
}),
db.frist.findUnique({
where: {
betreuteId_systemSchluessel: {
betreuteId: aktive.id,
systemSchluessel: 'pflegegrad-pruefung',
},
},
}),
])
const heute = heuteAlsTag()
const zuschlag = leistungszuschlagBerechnen({
einzugAm: heimvertrag?.einzugAm ?? aktive.heimEinzugAm,
eigenanteilCent: heimvertrag?.pflegeEigenanteilCent ?? null,
pflegegrad: aktive.pflegegrad,
stichtag: heute,
})
const abwesend = abwesenheitstageJeJahr(
ereignisse.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
abwesenheitstage: e.abwesenheitstage,
art: e.art,
})),
)
const jahre = verlaufAuswerten(
ereignisse.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
art: e.art,
abwesenheitstage: e.abwesenheitstage,
})),
)
const befund = verschlechterungErkannt(jahre, heute.getUTCFullYear())
const impfungen = impfstatusBerechnen(
ereignisse.filter((e) => e.art === 'IMPFUNG').map((e) => ({ datum: e.datum, titel: e.titel })),
heute,
)
const offenHeim = heimvertrag ? oeffnen('Heimvertrag', heimvertrag) : null
const gesamtCent =
(heimvertrag?.pflegeEigenanteilCent ?? 0n) +
(heimvertrag?.unterkunftVerpflegungCent ?? 0n) +
(heimvertrag?.investitionskostenCent ?? 0n) +
(heimvertrag?.ausbildungsumlageCent ?? 0n)
return (
<div className="space-y-6">
<SeitenKopf
titel="Heim & Pflege"
beschreibung="Heimvertrag, Leistungszuschlag, Pflegegrad und der Gesundheitsverlauf — die Grundlage für Jahresbericht und Höherstufung."
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Heim & Pflege' }]}
aktionen={
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/pflege/wille">
<ScrollText aria-hidden="true" />
Wille & Vorsorge
</Link>
</Button>
<Button asChild>
<Link href="/pflege/verlauf">
<HeartPulse aria-hidden="true" />
Gesundheitsverlauf
</Link>
</Button>
</div>
}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="size-5" aria-hidden="true" />
Heimvertrag
</CardTitle>
<CardDescription>
Einrichtung, Träger und die aktuellen Sätze. Historische Werte stehen in der
monatlichen Gegenüberstellung des Sozialhilfemoduls.
</CardDescription>
</CardHeader>
<CardContent>
{!heimvertrag ? (
<Leerzustand
titel="Noch kein Heimvertrag erfasst"
beschreibung="Ohne Einzugsdatum lässt sich der Leistungszuschlag nach § 43c SGB XI nicht berechnen — und der ist bares Geld."
icon={Building}
aktion={
schreiben && (
<Button asChild>
<Link href="/pflege/heimvertrag">Heimvertrag erfassen</Link>
</Button>
)
}
/>
) : (
<div className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y">
<Datenzeile beschriftung="Einrichtung">{heimvertrag.einrichtung}</Datenzeile>
<Datenzeile beschriftung="Träger">{heimvertrag.traeger ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Anschrift">
{[heimvertrag.strasse, [heimvertrag.plz, heimvertrag.ort].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ') || '–'}
</Datenzeile>
<Datenzeile beschriftung="Vertragsdatum">
{formatDatum(heimvertrag.vertragAm)}
</Datenzeile>
<Datenzeile beschriftung="Einzug">{formatDatum(heimvertrag.einzugAm)}</Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist">
{heimvertrag.kuendigungsfristTage !== null
? `${heimvertrag.kuendigungsfristTage} Tage`
: '–'}
</Datenzeile>
</dl>
<dl className="divide-y">
<Datenzeile beschriftung="Pflegebedingter Eigenanteil">
{formatCent(heimvertrag.pflegeEigenanteilCent)}
</Datenzeile>
<Datenzeile beschriftung="Unterkunft und Verpflegung">
{formatCent(heimvertrag.unterkunftVerpflegungCent)}
</Datenzeile>
<Datenzeile beschriftung="Investitionskosten">
{formatCent(heimvertrag.investitionskostenCent)}
</Datenzeile>
<Datenzeile beschriftung="Ausbildungsumlage">
{formatCent(heimvertrag.ausbildungsumlageCent)}
</Datenzeile>
<Datenzeile beschriftung="Heimkosten im Monat">
<strong>{formatCent(gesamtCent)}</strong>
</Datenzeile>
</dl>
{offenHeim?.notizen && (
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
<p className="whitespace-pre-line text-sm">{offenHeim.notizen}</p>
</div>
)}
{schreiben && (
<div className="mt-4 sm:col-span-2">
<Button asChild variant="outline">
<Link href="/pflege/heimvertrag">
<Pencil aria-hidden="true" />
Heimvertrag bearbeiten
</Link>
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
<LeistungszuschlagKarte
zuschlag={{
berechenbar: zuschlag.berechenbar,
verweildauerMonate: zuschlag.verweildauerMonate,
prozent: zuschlag.prozent,
stufeBezeichnung: zuschlag.stufe?.bezeichnung ?? null,
zuschlagCent: centZuZahl(zuschlag.zuschlagCent),
verbleibtCent: centZuZahl(zuschlag.verbleibtCent),
eigenanteilCent: centZuZahl(heimvertrag?.pflegeEigenanteilCent ?? null),
naechsteStufe: zuschlag.naechsteStufe
? {
prozent: zuschlag.naechsteStufe.stufe.prozent,
bezeichnung: zuschlag.naechsteStufe.stufe.bezeichnung,
abDatum: zuschlag.naechsteStufe.abDatum.toISOString(),
tageBis: zuschlag.naechsteStufe.tageBis,
mehrCent: centZuZahl(zuschlag.naechsteStufe.mehrCent),
}
: null,
hinweise: zuschlag.hinweise,
}}
/>
<PflegegradKarte
betreuteId={aktive.id}
darfSchreiben={schreiben}
pflegegrad={aktive.pflegegrad}
pflegegradSeit={aktive.pflegegradSeit?.toISOString() ?? null}
pflegegradBescheidVom={aktive.pflegegradBescheidVom?.toISOString() ?? null}
pruefungAm={pruefFrist?.datum.toISOString() ?? null}
vorschlagPruefungAm={alsIsoTag(plusMonate(heute, HOEHERSTUFUNG_ABSTAND_MONATE))}
verschlechterung={befund.begruendung}
begutachtungen={ereignisse
.filter((e) => e.art === 'PFLEGEBEGUTACHTUNG')
.map((e) => ({
id: e.id,
datum: e.datum.toISOString(),
titel: e.titel,
einrichtung: e.einrichtung,
}))}
/>
<AbwesenheitKarte jahre={abwesend} gesamt={abwesenheitstageGesamt(abwesend)} />
<Impfstatus
zeilen={impfungen.map((i) => ({
schluessel: i.empfehlung.schluessel,
bezeichnung: i.empfehlung.bezeichnung,
begruendung: i.empfehlung.begruendung,
letzteImpfung: i.letzteImpfung?.toISOString() ?? null,
faelligAm: i.faelligAm?.toISOString() ?? null,
lage: i.lage,
text: i.text,
}))}
/>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { EreignisFormular } from '@/components/pflege/ereignis-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { GESUNDHEITS_ARTEN } from '@/lib/validierung/pflege'
export const dynamic = 'force-dynamic'
export default async function EreignisAnlegen({
searchParams,
}: {
searchParams: Promise<{ art?: string }>
}) {
const benutzer = await erfordereBenutzer('/pflege/verlauf/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'GESUNDHEIT', 'lesen')
if (!darfSchreiben('GESUNDHEIT')) redirect('/pflege/verlauf')
const { art } = await searchParams
const vorgabe = GESUNDHEITS_ARTEN.find((a) => a === art) ?? 'EXAZERBATION'
return (
<div className="space-y-6">
<SeitenKopf
titel="Ereignis erfassen"
beschreibung="Schnellerfassung für die Fälle, die bei einer COPD immer wiederkehren. Die Zusatzfelder wechseln mit der Art."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Heim & Pflege', href: '/pflege' },
{ titel: 'Verlauf', href: '/pflege/verlauf' },
{ titel: 'Erfassen' },
]}
/>
<EreignisFormular
betreuteId={aktive.id}
werte={{
datum: alsIsoTag(heuteAlsTag()),
bisDatum: '',
art: vorgabe,
titel: '',
behandler: '',
einrichtung: aktive.heimName ?? '',
abwesenheitstage: '',
beschreibung: '',
details: {},
}}
/>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import Link from 'next/link'
import { Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { VerlaufAuswertung } from '@/components/pflege/verlauf-auswertung'
import { VerlaufZeitstrahl } from '@/components/pflege/verlauf-zeitstrahl'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { heuteAlsTag } from '@/lib/fristen/datum'
import {
verlaufAuswerten,
verlaufZusammenfassung,
verschlechterungErkannt,
} from '@/lib/pflege/verlauf'
export const dynamic = 'force-dynamic'
export default async function GesundheitsVerlauf() {
const benutzer = await erfordereBenutzer('/pflege/verlauf')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'GESUNDHEIT', 'lesen')
const schreiben = darfSchreiben('GESUNDHEIT')
const ereignisse = await db.gesundheitsereignis.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
orderBy: { datum: 'desc' },
})
const jahre = verlaufAuswerten(
ereignisse.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
art: e.art,
abwesenheitstage: e.abwesenheitstage,
})),
)
const befund = verschlechterungErkannt(jahre, heuteAlsTag().getUTCFullYear())
return (
<div className="space-y-6">
<SeitenKopf
titel="Gesundheitsverlauf"
beschreibung="Zeitstrahl aller erfassten Ereignisse. Beschreibungen und Zusatzangaben liegen verschlüsselt — Gesundheitsdaten sind besondere Kategorien nach Art. 9 DSGVO."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Heim & Pflege', href: '/pflege' },
{ titel: 'Verlauf' },
]}
aktionen={
schreiben && (
<Button asChild>
<Link href="/pflege/verlauf/neu">
<Plus aria-hidden="true" />
Ereignis erfassen
</Link>
</Button>
)
}
/>
<VerlaufAuswertung
verschlechterung={befund.begruendung}
zeilen={jahre.map((jahr) => ({
jahr: jahr.jahr,
exazerbationen: jahr.exazerbationen,
krankenhausaufenthalte: jahr.krankenhausaufenthalte,
krankenhaustage: jahr.krankenhaustage,
abwesenheitstage: jahr.abwesenheitstage,
stuerze: jahr.stuerze,
satz: verlaufZusammenfassung(jahr),
}))}
/>
<VerlaufZeitstrahl
betreuteId={aktive.id}
darfSchreiben={schreiben}
ereignisse={ereignisse.map((e) => {
const offen = oeffnen('Gesundheitsereignis', e)
return {
id: e.id,
datum: e.datum.toISOString(),
bisDatum: e.bisDatum?.toISOString() ?? null,
art: e.art,
titel: e.titel,
behandler: e.behandler,
einrichtung: e.einrichtung,
abwesenheitstage: e.abwesenheitstage,
beschreibung: offen.beschreibung,
}
})}
/>
<Rechtshinweis rechtsgrundlage="§ 18 SGB XI, § 1863 BGB" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: § 1863 BGB verlangt einmal im Jahr einen Bericht ans Betreuungsgericht,
und dazu gehört die persönliche Situation der betreuten Person. § 18 SGB XI regelt die
Begutachtung durch den Medizinischen Dienst — ein Antrag auf Neubegutachtung ist
jederzeit möglich, wenn sich der Hilfebedarf ändert. Beide Male ist dieser Verlauf die
Grundlage: Er macht aus „es geht ihr schlechter“ eine belegbare Zahl.
</p>
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
import Link from 'next/link'
import { FileText, Pencil, ScrollText, Upload } 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 { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { WilleFormular } from '@/components/pflege/wille-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function WilleUndVorsorge() {
const benutzer = await erfordereBenutzer('/pflege/wille')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'GESUNDHEIT', 'lesen')
const schreiben = darfSchreiben('GESUNDHEIT')
const unterlagen = await db.dokument.findMany({
where: {
betreuteId: aktive.id,
art: { in: ['VOLLMACHT', 'URKUNDE', 'ATTEST'] },
...nichtGeloescht,
},
select: { id: true, titel: true, art: true, schreibenVom: true, ablageortPapier: true },
orderBy: { schreibenVom: 'desc' },
take: 20,
})
const offen = oeffnen('Betreute', aktive)
return (
<div className="space-y-6">
<SeitenKopf
titel="Wille und Vorsorge"
beschreibung="Patientenverfügung, Vorsorgevollmacht, Behandlungswünsche — und der ermittelte Wille der betreuten Person."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Heim & Pflege', href: '/pflege' },
{ titel: 'Wille & Vorsorge' },
]}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ScrollText className="size-5" aria-hidden="true" />
Der ermittelte Wille
</CardTitle>
<CardDescription>
Seit der Betreuungsrechtsreform 2023 ist der Wille der betreuten Person die
Leitschnur des Betreuerhandelns — nicht mehr das, was objektiv vernünftig
erscheint.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: Du hast den Wünschen der betreuten Person zu entsprechen, auch wenn
du sie für unklug hältst. Erst wenn ein Wunsch ihre Person oder ihr Vermögen
erheblich gefährdet oder dir nicht zuzumuten ist, darfst du davon abweichen — und
das musst du begründen können. Kann sie ihren Willen nicht mehr äußern, gilt der
früher geäußerte oder der mutmaßliche Wille. Deshalb steht hier nicht deine
Einschätzung, sondern was sie gesagt hat, wann sie es gesagt hat und woher du es
weißt.
</p>
<WilleFormular
betreuteId={aktive.id}
darfSchreiben={schreiben}
ermittelterWille={offen.ermittelterWille}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-5" aria-hidden="true" />
Vorsorgeunterlagen
</CardTitle>
<CardDescription>
Die beiden Häkchen liegen an den Stammdaten. Ändern lassen sie sich dort — hier
stehen sie nur zur Übersicht.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-md border p-3">
<p className="text-sm text-muted-foreground">Patientenverfügung</p>
<Badge variant={aktive.patientenverfuegungVorhanden ? 'gruen' : 'gelb'}>
{aktive.patientenverfuegungVorhanden ? 'vorhanden' : 'nicht vorhanden'}
</Badge>
<p className="mt-2 text-sm text-muted-foreground">
Liegt eine wirksame Patientenverfügung vor und passt sie auf die aktuelle
Behandlungssituation, gilt sie unmittelbar — dann hast du ihr Ausdruck und
Geltung zu verschaffen (§ 1827 BGB). Eine Genehmigung des Gerichts braucht es
nur, wenn Uneinigkeit mit dem Arzt besteht.
</p>
</div>
<div className="rounded-md border p-3">
<p className="text-sm text-muted-foreground">Vorsorgevollmacht</p>
<Badge variant={aktive.vorsorgevollmachtVorhanden ? 'gruen' : 'gelb'}>
{aktive.vorsorgevollmachtVorhanden ? 'vorhanden' : 'nicht vorhanden'}
</Badge>
<p className="mt-2 text-sm text-muted-foreground">
Eine wirksame Vorsorgevollmacht geht der Betreuung vor. Gibt es sie für Bereiche,
die dein Aufgabenkreis abdeckt, gehört das dem Gericht mitgeteilt — dann ist die
Betreuung insoweit nicht erforderlich.
</p>
</div>
</div>
<Button asChild variant="outline">
<Link href="/stammdaten/bearbeiten">
<Pencil aria-hidden="true" />
In den Stammdaten ändern
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Abgelegte Unterlagen</CardTitle>
<CardDescription>
Vollmachten, Urkunden und Atteste aus der Dokumentenablage. Im Notfall zählt, dass
das Papier greifbar ist — notier deshalb immer auch, wo das Original liegt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{unterlagen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch nichts abgelegt. Scanne Patientenverfügung und Vorsorgevollmacht ein und
hinterlege beim Dokument, wo das Original liegt.
</p>
) : (
<ul className="space-y-2">
{unterlagen.map((dokument) => (
<li
key={dokument.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3"
>
<Link
href={`/dokumente/${dokument.id}`}
className="font-medium hover:underline"
>
{dokument.titel}
</Link>
<span className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{dokument.art}</Badge>
{dokument.schreibenVom && <span>{formatDatum(dokument.schreibenVom)}</span>}
{dokument.ablageortPapier && <span>Original: {dokument.ablageortPapier}</span>}
</span>
</li>
))}
</ul>
)}
<Button asChild variant="outline">
<Link href="/dokumente/neu?art=VOLLMACHT">
<Upload aria-hidden="true" />
Unterlage hochladen
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,77 @@
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string {
if (cent === null) return ''
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const euro = betrag / 100n
const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
}
export default async function VertragBearbeiten({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' },
]}
/>
<VertragFormular
betreuteId={aktive.id}
werte={{
id: vertrag.id,
bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art,
status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '',
}}
/>
</div>
)
}
@@ -0,0 +1,96 @@
import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
art: vertrag.art,
laufzeit: {
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
},
heute,
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' },
]}
/>
<KuendigungsAssistent
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)}
daten={{
schreiben: paket.schreiben,
warnungen: paket.warnungen,
genehmigung: paket.genehmigung,
versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis,
},
plan: {
berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin)
: null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise,
},
}}
/>
</div>
)
}
+266
View File
@@ -0,0 +1,266 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } 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 { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import {
kuendigungsampel,
kuendigungsplan,
laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung'
import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
jahresbeitragCent,
kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
vermoegensposten: {
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
},
},
})
if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag()
const plan = kuendigungsplan({
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
heute,
})
const ampel = kuendigungsampel(plan)
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
const kopf = briefkopfBauen({ betreute: aktive, benutzer })
const anschreiben = rueckkaufswertAnschreiben({
kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
stichtag: heute,
heute,
})
const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? []
return (
<div className="space-y-6">
<SeitenKopf
titel={vertrag.bezeichnung}
beschreibung={
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean)
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung },
]}
aktionen={
schreiben && (
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href={`/vertraege/${id}/kuendigen`}>
<FileX2 aria-hidden="true" />
Kündigen
</Link>
</Button>
<Button asChild>
<Link href={`/vertraege/${id}/bearbeiten`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
</div>
)
}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" aria-hidden="true" />
Vertragsdaten
</CardTitle>
<CardDescription>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y">
<Datenzeile beschriftung="Vertragsnummer">
{offen.vertragsnummer ?? '–'}
</Datenzeile>
<Datenzeile beschriftung="Status">
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile>
<Datenzeile beschriftung="Laufzeitbeginn">
{formatDatum(vertrag.beginnAm)}
</Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende">
{formatDatum(vertrag.endeAm)}
</Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist">
{vertrag.kuendigungsfristMonate !== null
? `${vertrag.kuendigungsfristMonate} Monate`
: 'nicht erfasst'}
</Datenzeile>
</dl>
<dl className="divide-y">
<Datenzeile beschriftung="Beitrag">
{formatCent(vertrag.beitragCent)}
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
</Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.gekuendigtAm)}
</Datenzeile>
<Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)}
</Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</Datenzeile>
</dl>
{offen.notizen && (
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Kündigungsfrist</CardTitle>
<CardDescription>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
Absendetag früher als der Fristablauf.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{plan.berechenbar ? (
<>
<dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.zugangSpaetestensAm)}
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
<dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)}
</dd>
{ampel && (
<dd className="mt-1">
<Badge variant={ampel.variante}>{ampel.text}</Badge>
</dd>
)}
</div>
</dl>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{plan.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
</>
) : (
<p className="text-sm text-muted-foreground">
{plan.hinweise[0] ??
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
</p>
)}
</CardContent>
</Card>
{kannRueckkaufswertHaben(vertrag.art) && (
<Rueckkaufswert
betreuteId={aktive.id}
vertragId={id}
darfSchreiben={schreiben}
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig}
tageSeitAnfrage={anfrage.tageSeitAnfrage}
anschreiben={anschreiben}
stichtagVorschlag={alsIsoTag(heute)}
werte={wertstaende.map((w) => ({
id: w.id,
stichtag: w.stichtag.toISOString(),
wertCent: centZuZahl(w.wertCent) ?? 0,
wertquelle: w.wertquelle,
bemerkung: w.bemerkung,
}))}
/>
)}
<Card>
<CardHeader>
<CardTitle>Bewertung fürs Sozialamt</CardTitle>
<CardDescription>
Meine Einordnung und meine Begründung — keine Rechtsauskunft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground">
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p>
{offen.bewertungBegruendung ? (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{offen.bewertungBegruendung}
</p>
) : (
<p className="text-sm text-muted-foreground">
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht.
</p>
)}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent>
</Card>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
export const dynamic = 'force-dynamic'
export default async function VertragAnlegen() {
const benutzer = await erfordereBenutzer('/vertraege/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
return (
<div className="space-y-6">
<SeitenKopf
titel="Vertrag anlegen"
beschreibung="Beginn und Kündigungsfrist sind die beiden Angaben, aus denen alles Weitere folgt. Stehen sie nicht in der Police, frag bei der Gesellschaft nach."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' },
{ titel: 'Anlegen' },
]}
/>
<VertragFormular betreuteId={aktive.id} werte={LEERER_VERTRAG} />
</div>
)
}
+235
View File
@@ -0,0 +1,235 @@
import Link from 'next/link'
import { Plus, Shield } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
KuendigungsUebersicht,
type Kuendigungswarnung,
} from '@/components/vertraege/kuendigungs-uebersicht'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { heuteAlsTag } from '@/lib/fristen/datum'
import {
kuendigungsampel,
kuendigungsplan,
laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung'
import {
BEWERTUNG_BESCHRIFTUNG,
jahresbeitragCent,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
const BEWERTUNG_VARIANTE = {
UNGEPRUEFT: 'outline',
VERWERTBAR: 'orange',
GESCHUETZT: 'gruen',
STRITTIG: 'gelb',
} as const
export default async function VertraegeUebersicht() {
const benutzer = await erfordereBenutzer('/vertraege')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE')
const vertraege = await db.vertrag.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
include: {
vermoegensposten: {
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
},
},
orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }],
})
const heute = heuteAlsTag()
const warnungen: Kuendigungswarnung[] = []
let jahresbeitragSumme = 0n
const zeilen = vertraege.map((vertrag) => {
const offen = oeffnen('Vertrag', vertrag)
const plan = kuendigungsplan({
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
heute,
})
const ampel = kuendigungsampel(plan)
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI'
if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) {
warnungen.push({
vertragId: vertrag.id,
bezeichnung: vertrag.bezeichnung,
absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null,
tageBisAbsendetag: plan.tageBisAbsendetag,
verpasst: plan.verpasst,
variante: ampel.variante,
text: ampel.text,
})
}
return {
id: vertrag.id,
bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft,
art: vertrag.art,
status: vertrag.status,
vertragsnummer: offen.vertragsnummer,
beitragCent: centZuZahl(vertrag.beitragCent),
rhythmus: vertrag.rhythmus,
bewertung: vertrag.bewertungSozialhilfe,
rueckkaufswertCent: centZuZahl(
vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null,
),
rueckkaufswertStichtag:
vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null,
ampel,
}
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Versicherungen & Verträge"
beschreibung="Alle laufenden Verträge mit Beitrag, Kündigungsfrist, Rückkaufswert und der Einordnung fürs Sozialamt."
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
aktionen={
schreiben && (
<Button asChild>
<Link href="/vertraege/neu">
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
)
}
/>
<KuendigungsUebersicht warnungen={warnungen} />
{vertraege.length === 0 ? (
<Leerzustand
titel="Noch kein Vertrag erfasst"
beschreibung="Nimm die Kontoauszüge des letzten Jahres zur Hand: Jede wiederkehrende Abbuchung ist ein Vertrag. Das ist der zuverlässigste Weg, nichts zu übersehen — Policen liegen selten vollständig im Ordner."
icon={Shield}
aktion={
schreiben && (
<Button asChild>
<Link href="/vertraege/neu">
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
)
}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Alle Verträge</CardTitle>
<CardDescription>
Jahresbeitrag der aktiven Verträge zusammen:{' '}
<strong className="text-foreground">
{formatCent(centZuZahl(jahresbeitragSumme))}
</strong>
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Vertrag</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Beitrag</TableHead>
<TableHead className="text-right">Rückkaufswert</TableHead>
<TableHead>Kündigung</TableHead>
<TableHead>Sozialamt</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell>
<Link
href={`/vertraege/${zeile.id}`}
className="font-medium hover:underline"
>
{zeile.bezeichnung}
</Link>
<span className="block text-sm text-muted-foreground">
{[zeile.gesellschaft, zeile.vertragsnummer].filter(Boolean).join(' · ') ||
'Gesellschaft nicht erfasst'}
</span>
</TableCell>
<TableCell className="text-sm">
{VERTRAG_ART_BESCHRIFTUNG[zeile.art]}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(zeile.beitragCent)}
{zeile.rhythmus && (
<span className="block text-xs text-muted-foreground">
{RHYTHMUS_BESCHRIFTUNG[zeile.rhythmus]}
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(zeile.rueckkaufswertCent)}
{zeile.rueckkaufswertStichtag && (
<span className="block text-xs text-muted-foreground">
{formatDatum(zeile.rueckkaufswertStichtag)}
</span>
)}
</TableCell>
<TableCell>
{zeile.ampel ? (
<Badge variant={zeile.ampel.variante}>{zeile.ampel.text}</Badge>
) : (
<span className="text-sm text-muted-foreground">nicht berechenbar</span>
)}
</TableCell>
<TableCell>
<Badge variant={BEWERTUNG_VARIANTE[zeile.bewertung]}>
{BEWERTUNG_BESCHRIFTUNG[zeile.bewertung]}
</Badge>
</TableCell>
<TableCell className="text-sm">
{VERTRAG_STATUS_BESCHRIFTUNG[zeile.status]}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 1850 BGB" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: Ob ein Vertrag vor der Sozialhilfe einzusetzen ist, richtet sich nach
§ 90 SGB XII — eine angemessene Bestattungsvorsorge bleibt regelmäßig geschützt. Und
bevor eine Lebens- oder Rentenversicherung verwertet wird, gehört die Frage nach der
Genehmigung des Betreuungsgerichts (§ 1850 BGB) an den Rechtspfleger, nicht an diese App.
</p>
</div>
)
}
@@ -0,0 +1,83 @@
import Link from 'next/link'
import { BedDouble } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand } from '@/components/seiten-kopf'
import { ABWESENHEIT_HINWEIS } from '@/lib/pflege/konstanten'
/**
* Abwesenheitstage je Jahr - Erfassung laeuft ueber den
* Gesundheitsverlauf, hier steht nur die Summe.
*/
export function AbwesenheitKarte({
jahre,
gesamt,
}: {
jahre: { jahr: number; tage: number; ereignisse: number }[]
gesamt: number
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BedDouble className="size-5" aria-hidden="true" />
Abwesenheitstage
</CardTitle>
<CardDescription>
Tage außerhalb der Einrichtung, vor allem im Krankenhaus. Erfasst werden sie im
Gesundheitsverlauf; hier stehen die Jahressummen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{jahre.length === 0 ? (
<Leerzustand
titel="Noch keine Abwesenheitstage erfasst"
beschreibung="Jeder Krankenhausaufenthalt mit Aufnahme- und Entlassdatum zählt hier automatisch mit."
icon={BedDouble}
aktion={
<Button asChild variant="outline">
<Link href="/pflege/verlauf/neu?art=KRANKENHAUS">
Krankenhausaufenthalt erfassen
</Link>
</Button>
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Jahr</TableHead>
<TableHead className="text-right">Ereignisse</TableHead>
<TableHead className="text-right">Abwesenheitstage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jahre.map((jahr) => (
<TableRow key={jahr.jahr}>
<TableCell className="font-medium">{jahr.jahr}</TableCell>
<TableCell className="text-right tabular-nums">{jahr.ereignisse}</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{jahr.tage}
</TableCell>
</TableRow>
))}
<TableRow className="border-t-2">
<TableCell className="font-semibold">insgesamt</TableCell>
<TableCell />
<TableCell className="text-right font-semibold tabular-nums">{gesamt}</TableCell>
</TableRow>
</TableBody>
</Table>
</>
)}
<p className="rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground">
{ABWESENHEIT_HINWEIS}
</p>
</CardContent>
</Card>
)
}
+275
View File
@@ -0,0 +1,275 @@
'use client'
import { useActionState, useState } from 'react'
import type { GesundheitsArt } from '@prisma/client'
import { Lock } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { detailfelderFuer } from '@/lib/pflege/ereignis-details'
import {
EXAZERBATION_AUSLOESER,
GESUNDHEITS_ART_BESCHRIFTUNG,
SCHNELLERFASSUNG_REIHENFOLGE,
} from '@/lib/pflege/konstanten'
import { gesundheitsereignisSpeichern } from '@/server/actions/pflege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type EreignisWerte = {
id?: string
datum: string
bisDatum: string
art: GesundheitsArt
titel: string
behandler: string
einrichtung: string
abwesenheitstage: string
beschreibung: string
details: Record<string, string>
}
/** Vorschlag für den Titel, damit die Schnellerfassung wirklich schnell ist. */
const TITELVORSCHLAG: Partial<Record<GesundheitsArt, string>> = {
EXAZERBATION: 'Exazerbation',
KRANKENHAUS: 'Krankenhausaufenthalt',
SAUERSTOFFVERSORGUNG: 'Sauerstoffversorgung angepasst',
MEDIKATIONSAENDERUNG: 'Medikation geändert',
INHALATION: 'Inhalation angepasst',
STURZ: 'Sturz',
HILFSMITTEL: 'Hilfsmittel',
IMPFUNG: 'Grippeimpfung',
ARZTBESUCH: 'Arztbesuch',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtung',
}
export function EreignisFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: EreignisWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
gesundheitsereignisSpeichern,
{},
)
const [art, setArt] = useState<GesundheitsArt>(werte.art)
const [titelBeruehrt, setTitelBeruehrt] = useState(Boolean(werte.titel))
const felder = detailfelderFuer(art)
const zeitraum = art === 'KRANKENHAUS' || art === 'REHA'
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="ereignisId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Alert variant="hinweis">
<Lock aria-hidden="true" />
<AlertDescription>
Gesundheitsdaten sind besondere Kategorien nach Art. 9 DSGVO. Die Beschreibung und
alle Zusatzangaben werden verschlüsselt gespeichert; im Protokoll steht später nur
der Titel, nie der Inhalt. Schreib den Titel deshalb so, dass er ohne Diagnose
auskommt.
</AlertDescription>
</Alert>
<Feldgruppe titel="Ereignis" beschreibung="Was ist passiert und wann.">
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select
name="art"
value={art}
onChange={(e) => setArt(e.target.value as GesundheitsArt)}
>
{SCHNELLERFASSUNG_REIHENFOLGE.map((a) => (
<option key={a} value={a}>
{GESUNDHEITS_ART_BESCHRIFTUNG[a]}
</option>
))}
</Select>
</Feld>
<Feld
id="titel"
beschriftung="Titel"
erklaerung="Kurz und ohne Diagnose — er steht unverschlüsselt in der Liste."
fehler={zustand.felder?.titel}
pflicht
>
<Input
name="titel"
required
maxLength={200}
key={titelBeruehrt ? 'fest' : art}
defaultValue={titelBeruehrt ? werte.titel : (TITELVORSCHLAG[art] ?? werte.titel)}
onChange={() => setTitelBeruehrt(true)}
/>
</Feld>
<Feld
id="datum"
beschriftung={zeitraum ? 'Aufnahme am' : 'Datum'}
fehler={zustand.felder?.datum}
pflicht
>
<Input name="datum" type="date" defaultValue={werte.datum} required />
</Feld>
<Feld
id="bisDatum"
beschriftung={zeitraum ? 'Entlassung am' : 'Ende (falls Zeitraum)'}
erklaerung={
zeitraum
? 'Aus Aufnahme und Entlassung werden die Abwesenheitstage gerechnet — der Entlassungstag zählt nicht mit.'
: undefined
}
fehler={zustand.felder?.bisDatum}
>
<Input name="bisDatum" type="date" defaultValue={werte.bisDatum} />
</Feld>
<Feld
id="behandler"
beschriftung="Behandler"
erklaerung="Arzt, Pflegedienstleitung, Gutachter."
fehler={zustand.felder?.behandler}
>
<Input name="behandler" defaultValue={werte.behandler} maxLength={150} />
</Feld>
<Feld
id="einrichtung"
beschriftung="Einrichtung"
fehler={zustand.felder?.einrichtung}
>
<Input name="einrichtung" defaultValue={werte.einrichtung} maxLength={150} />
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
erklaerung="Nur ausfüllen, wenn du die Tage abweichend zählen willst — sonst werden sie aus dem Zeitraum gerechnet. Was gilt, steht im Heimvertrag."
fehler={zustand.felder?.abwesenheitstage}
>
<Input
name="abwesenheitstage"
inputMode="numeric"
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
{felder.length > 0 && (
<Feldgruppe
titel="Zusatzangaben"
beschreibung="Die Angaben, die bei dieser Art von Ereignis später zählen. Sie werden verschlüsselt in die Beschreibung geschrieben."
>
{felder.map((feld) => {
const id = `detail_${feld.name}`
const wert = werte.details[feld.name] ?? ''
if (feld.typ === 'jaNein') {
return (
<div key={feld.name} className="sm:col-span-2">
<label className="flex items-start gap-2.5">
<Checkbox name={id} defaultChecked={wert === 'ja'} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">{feld.beschriftung}</span>
{feld.erklaerung && (
<span className="block text-muted-foreground">{feld.erklaerung}</span>
)}
</span>
</label>
</div>
)
}
if (feld.typ === 'auswahl') {
return (
<Feld
key={feld.name}
id={id}
beschriftung={feld.beschriftung}
erklaerung={feld.erklaerung}
pflicht={feld.pflicht}
>
<Select name={id} defaultValue={wert}>
<option value="">— bitte wählen —</option>
{(feld.optionen ?? []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</Feld>
)
}
if (feld.typ === 'mehrzeilig') {
return (
<Feld
key={feld.name}
id={id}
beschriftung={feld.beschriftung}
erklaerung={feld.erklaerung}
className="sm:col-span-2"
>
<Textarea name={id} defaultValue={wert} rows={2} maxLength={1000} />
</Feld>
)
}
return (
<Feld
key={feld.name}
id={id}
beschriftung={
feld.einheit ? `${feld.beschriftung} (${feld.einheit})` : feld.beschriftung
}
erklaerung={feld.erklaerung}
pflicht={feld.pflicht}
>
<Input
name={id}
defaultValue={wert}
inputMode={feld.typ === 'zahl' ? 'decimal' : 'text'}
maxLength={200}
list={
feld.name === 'ausloeser' && art === 'EXAZERBATION'
? 'ausloeser-vorschlaege'
: undefined
}
/>
</Feld>
)
})}
{art === 'EXAZERBATION' && (
<datalist id="ausloeser-vorschlaege">
{EXAZERBATION_AUSLOESER.map((a) => (
<option key={a} value={a} />
))}
</datalist>
)}
</Feldgruppe>
)}
<Feld
id="beschreibung"
beschriftung="Weitere Beschreibung"
erklaerung="Wird verschlüsselt gespeichert. Hier gehört hinein, was du später im Jahresbericht oder bei einer Begutachtung brauchst."
fehler={zustand.felder?.beschreibung}
>
<Textarea name="beschreibung" defaultValue={werte.beschreibung} rows={5} maxLength={4000} />
</Feld>
<Absendeknopf>{werte.id ? 'Änderungen speichern' : 'Eintrag anlegen'}</Absendeknopf>
</form>
)
}
@@ -0,0 +1,196 @@
'use client'
import { useActionState } from 'react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { heimvertragSpeichern } from '@/server/actions/pflege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type HeimvertragWerte = {
einrichtung: string
traeger: string
strasse: string
plz: string
ort: string
vertragAm: string
einzugAm: string
auszugAm: string
kuendigungsfristTage: string
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
notizen: string
}
export const LEERER_HEIMVERTRAG: HeimvertragWerte = {
einrichtung: '',
traeger: '',
strasse: '',
plz: '',
ort: '',
vertragAm: '',
einzugAm: '',
auszugAm: '',
kuendigungsfristTage: '14',
pflegeEigenanteil: '',
unterkunftVerpflegung: '',
investitionskosten: '',
ausbildungsumlage: '',
notizen: '',
}
export function HeimvertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: HeimvertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimvertragSpeichern, {})
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Einrichtung" beschreibung="Wer betreibt das Heim und wo liegt es.">
<Feld
id="einrichtung"
beschriftung="Einrichtung"
fehler={zustand.felder?.einrichtung}
pflicht
>
<Input name="einrichtung" defaultValue={werte.einrichtung} required maxLength={150} />
</Feld>
<Feld
id="traeger"
beschriftung="Träger"
erklaerung="Wer betreibt das Heim? Bei Streit über die Rechnung ist der Träger der Vertragspartner, nicht die Heimleitung."
fehler={zustand.felder?.traeger}
>
<Input name="traeger" defaultValue={werte.traeger} maxLength={150} />
</Feld>
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input name="strasse" defaultValue={werte.strasse} maxLength={150} />
</Feld>
<div className="grid grid-cols-[7rem_1fr] gap-3">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input name="plz" defaultValue={werte.plz} inputMode="numeric" maxLength={10} />
</Feld>
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
<Input name="ort" defaultValue={werte.ort} maxLength={100} />
</Feld>
</div>
</Feldgruppe>
<Feldgruppe
titel="Vertrag"
beschreibung="Das Einzugsdatum steuert den Leistungszuschlag nach § 43c SGB XI — trag es unbedingt ein."
>
<Feld id="vertragAm" beschriftung="Vertragsdatum" fehler={zustand.felder?.vertragAm}>
<Input name="vertragAm" type="date" defaultValue={werte.vertragAm} />
</Feld>
<Feld
id="einzugAm"
beschriftung="Einzug am"
erklaerung="Ab diesem Tag zählt die Verweildauer für den Leistungszuschlag."
fehler={zustand.felder?.einzugAm}
>
<Input name="einzugAm" type="date" defaultValue={werte.einzugAm} />
</Feld>
<Feld id="auszugAm" beschriftung="Auszug am" fehler={zustand.felder?.auszugAm}>
<Input name="auszugAm" type="date" defaultValue={werte.auszugAm} />
</Feld>
<Feld
id="kuendigungsfristTage"
beschriftung="Kündigungsfrist in Tagen"
erklaerung="Nach dem WBVG kann die Bewohnerseite spätestens am dritten Werktag eines Monats zum Monatsende kündigen. Trag ein, was im Vertrag steht."
fehler={zustand.felder?.kuendigungsfristTage}
>
<Input
name="kuendigungsfristTage"
inputMode="numeric"
defaultValue={werte.kuendigungsfristTage}
/>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Aktuelle Sätze im Monat"
beschreibung="Die vier Bestandteile der Heimrechnung. Historische Werte bleiben in der monatlichen Gegenüberstellung erhalten."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Pflegebedingter Eigenanteil (EEE)"
erklaerung="Der einrichtungseinheitliche Eigenanteil — für alle Pflegegrade ab 2 gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
fehler={zustand.felder?.pflegeEigenanteil}
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
placeholder="1.450,00"
/>
</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"
erklaerung="Anteil an Gebäude und Ausstattung. Wird vom Leistungszuschlag nicht erfasst."
fehler={zustand.felder?.investitionskosten}
>
<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>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert. Hier gehört hinein, was der Heimvertrag zu Abwesenheitstagen regelt."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Absendeknopf>Heimvertrag speichern</Absendeknopf>
</form>
)
}
+84
View File
@@ -0,0 +1,84 @@
import Link from 'next/link'
import { Syringe } 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 { IMPFPLAN_STAND } from '@/lib/pflege/impfungen'
import { formatDatum } from '@/lib/utils'
export type ImpfZeile = {
schluessel: string
bezeichnung: string
begruendung: string
letzteImpfung: string | null
faelligAm: string | null
lage: 'unbekannt' | 'faellig' | 'bald' | 'erledigt'
text: string
}
const VARIANTE = {
unbekannt: 'gelb',
faellig: 'rot',
bald: 'orange',
erledigt: 'gruen',
} as const
const LAGE_TEXT = {
unbekannt: 'nicht dokumentiert',
faellig: 'fällig',
bald: 'bald fällig',
erledigt: 'aktuell',
} as const
export function Impfstatus({ zeilen }: { zeilen: ImpfZeile[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Syringe className="size-5" aria-hidden="true" />
Impfungen
</CardTitle>
<CardDescription>
Bei COPD ausdrücklich empfohlen: Jede Atemwegsinfektion kann eine Exazerbation
auslösen, und jede Exazerbation kostet Lungenfunktion, die nicht zurückkommt. Stand
der zugrunde liegenden STIKO-Empfehlungen: {IMPFPLAN_STAND}.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ul className="space-y-2">
{zeilen.map((zeile) => (
<li key={zeile.schluessel} className="rounded-md border p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium">{zeile.bezeichnung}</span>
<Badge variant={VARIANTE[zeile.lage]}>{LAGE_TEXT[zeile.lage]}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{zeile.begruendung}</p>
<p className="mt-1 text-sm">
{zeile.letzteImpfung
? `Zuletzt am ${formatDatum(zeile.letzteImpfung)}. ${zeile.text}`
: zeile.text}
{zeile.faelligAm && ` (${formatDatum(zeile.faelligAm)})`}
</p>
</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/pflege/verlauf/neu?art=IMPFUNG">Impfung eintragen</Link>
</Button>
<span className="text-sm text-muted-foreground">
Erkannt wird die Impfung am Titel — schreib „Grippeimpfung“, „Pneumokokken“,
„COVID-19“ oder „RSV“ hinein.
</span>
</div>
<p className="text-sm text-muted-foreground">
Ob geimpft wird, entscheidet die betreute Person. Kann sie es nicht mehr, entscheidest
du nach ihrem Willen — nach ärztlicher Aufklärung und dokumentiert.
</p>
</CardContent>
</Card>
)
}
@@ -0,0 +1,178 @@
import Link from 'next/link'
import { ArrowUpRight, TrendingUp } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE,
LEISTUNGSZUSCHLAG_STAFFEL,
LEISTUNGSZUSCHLAG_STAND,
} from '@/lib/pflege/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Anzeige des Leistungszuschlags - reine Darstellung.
*
* Die monatliche Gegenueberstellung von Heimrechnung und Deckungsquellen
* baut das Sozialhilfemodul. Hier wird nur gezeigt, in welcher Stufe die
* betreute Person gerade ist und wann die naechste greift, und dann auf
* /sozialhilfe/heimkosten verlinkt.
*/
export type ZuschlagAnzeige = {
berechenbar: boolean
verweildauerMonate: number
prozent: number
stufeBezeichnung: string | null
zuschlagCent: number | null
verbleibtCent: number | null
eigenanteilCent: number | null
naechsteStufe: {
prozent: number
bezeichnung: string
abDatum: string
tageBis: number
mehrCent: number | null
} | null
hinweise: string[]
}
export function LeistungszuschlagKarte({ zuschlag }: { zuschlag: ZuschlagAnzeige }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="size-5" aria-hidden="true" />
Leistungszuschlag
</CardTitle>
<CardDescription>
Die Pflegekasse übernimmt einen wachsenden Anteil des pflegebedingten Eigenanteils,
je länger jemand in der Einrichtung lebt. Beantragen muss man ihn nicht — die
Einrichtung rechnet ihn ab. Genau deshalb fällt es niemandem auf, wenn eine Stufe
nicht umgestellt wurde.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{zuschlag.berechenbar ? (
<>
<dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Verweildauer</dt>
<dd className="text-lg font-semibold">
{zuschlag.verweildauerMonate} Monate
</dd>
{zuschlag.stufeBezeichnung && (
<dd className="text-sm text-muted-foreground">{zuschlag.stufeBezeichnung}</dd>
)}
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Aktuelle Stufe</dt>
<dd className="text-lg font-semibold">{zuschlag.prozent} %</dd>
<dd className="text-sm text-muted-foreground">
{zuschlag.zuschlagCent !== null
? `${formatCent(zuschlag.zuschlagCent)} im Monat`
: 'Betrag unbekannt'}
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Verbleibender Pflegeanteil</dt>
<dd className="text-lg font-semibold">
{formatCent(zuschlag.verbleibtCent)}
</dd>
<dd className="text-sm text-muted-foreground">
von {formatCent(zuschlag.eigenanteilCent)}
</dd>
</div>
</dl>
{zuschlag.naechsteStufe && (
<div className="rounded-md border border-ampel-gruen/40 bg-ampel-gruen/5 p-4">
<p className="flex flex-wrap items-center gap-2 font-medium">
<ArrowUpRight className="size-4 text-ampel-gruen" aria-hidden="true" />
Nächste Stufe: {zuschlag.naechsteStufe.prozent} % ab dem{' '}
{formatDatum(zuschlag.naechsteStufe.abDatum)}
<Badge variant="gruen">
{zuschlag.naechsteStufe.tageBis > 0
? `in ${zuschlag.naechsteStufe.tageBis} Tagen`
: 'greift bereits'}
</Badge>
</p>
{zuschlag.naechsteStufe.mehrCent !== null && (
<p className="mt-1 text-sm text-muted-foreground">
Das sind{' '}
<strong className="text-foreground">
{formatCent(zuschlag.naechsteStufe.mehrCent)}
</strong>{' '}
mehr im Monat, die die Pflegekasse übernimmt. Prüf die erste Heimrechnung
nach diesem Datum daraufhin — und wenn die Stufe fehlt, reklamier sie beim
Träger.
</p>
)}
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
{zuschlag.hinweise[0] ?? 'Ohne Einzugsdatum lässt sich nichts berechnen.'}
</p>
)}
<div>
<h3 className="mb-2 text-sm font-semibold">
Staffel nach Verweildauer (Stand {LEISTUNGSZUSCHLAG_STAND})
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitraum</TableHead>
<TableHead>Monate</TableHead>
<TableHead className="text-right">Zuschlag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{LEISTUNGSZUSCHLAG_STAFFEL.map((stufe) => (
<TableRow
key={stufe.abMonat}
className={stufe.prozent === zuschlag.prozent && zuschlag.berechenbar ? 'bg-muted/60' : undefined}
>
<TableCell>{stufe.bezeichnung}</TableCell>
<TableCell className="tabular-nums">
{stufe.bisMonat === null
? `ab ${stufe.abMonat}`
: `${stufe.abMonat}–${stufe.bisMonat}`}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{stufe.prozent} %
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="mt-2 text-sm text-muted-foreground">
Der Gesetzgeber ändert diese Staffel: Zum 01.01.2024 wurde sie von 5/25/45/70 auf
15/30/50/75 Prozent angehoben. Prüf den Stand, bevor du mit den Zahlen argumentierst.
</p>
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{zuschlag.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline">
<Link href="/sozialhilfe/heimkosten">Monatliche Gegenüberstellung ansehen</Link>
</Button>
<span className="text-sm text-muted-foreground">
Heimrechnung gegen Deckungsquellen — die führt das Sozialhilfemodul.
</span>
</div>
<Rechtshinweis rechtsgrundlage={LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE} />
</CardContent>
</Card>
)
}
+200
View File
@@ -0,0 +1,200 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { Activity, CalendarClock, Pencil, Stethoscope } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Datenzeile, Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
PFLEGEGRAD_BESCHRIFTUNG,
PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG,
} from '@/lib/pflege/konstanten'
import { hoeherstufungsterminSetzen } from '@/server/actions/pflege'
import { formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type BegutachtungZeile = {
id: string
datum: string
titel: string
einrichtung: string | null
}
export function PflegegradKarte({
betreuteId,
pflegegrad,
pflegegradSeit,
pflegegradBescheidVom,
pruefungAm,
vorschlagPruefungAm,
begutachtungen,
verschlechterung,
darfSchreiben,
}: {
betreuteId: string
pflegegrad: number | null
pflegegradSeit: string | null
pflegegradBescheidVom: string | null
pruefungAm: string | null
vorschlagPruefungAm: string
begutachtungen: BegutachtungZeile[]
verschlechterung: string | null
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
hoeherstufungsterminSetzen,
{},
)
const [offen, setOffen] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5" aria-hidden="true" />
Pflegegrad
</CardTitle>
<CardDescription>
Der aktuelle Grad, seine Historie und der Termin, an dem geprüft wird, ob eine
Höherstufung sinnvoll ist.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
{pflegegrad ? (
<Badge variant="default" className="text-sm">
{PFLEGEGRAD_BESCHRIFTUNG[pflegegrad] ?? `Pflegegrad ${pflegegrad}`}
</Badge>
) : (
<Badge variant="gelb">kein Pflegegrad erfasst</Badge>
)}
<Button asChild variant="ghost" size="sm">
<Link href="/stammdaten/bearbeiten">
<Pencil aria-hidden="true" />
In den Stammdaten ändern
</Link>
</Button>
</div>
<dl className="divide-y">
<Datenzeile beschriftung="Pflegegrad seit">{formatDatum(pflegegradSeit)}</Datenzeile>
<Datenzeile beschriftung="Bescheid vom">
{formatDatum(pflegegradBescheidVom)}
</Datenzeile>
<Datenzeile beschriftung="Nächste Überprüfung">
{pruefungAm ? formatDatum(pruefungAm) : 'kein Termin gesetzt'}
</Datenzeile>
</dl>
<Alert variant="hinweis">
<Stethoscope aria-hidden="true" />
<AlertTitle>COPD verschlechtert sich in Schüben</AlertTitle>
<AlertDescription>
<p>
Eine chronisch obstruktive Lungenerkrankung schreitet fort. Nimmt der Hilfebedarf
zu — mehr Unterstützung beim Waschen und Ankleiden, Atemnot schon bei kleinen
Wegen, häufigere Schübe, Sauerstoff über mehr Stunden am Tag —, rechtfertigt das
einen Antrag auf Neubegutachtung. Ein solcher Antrag ist jederzeit möglich und
kostet nichts; die Pflegekasse muss ihn bearbeiten.
</p>
{verschlechterung && (
<p className="mt-2 font-medium text-foreground">{verschlechterung}</p>
)}
<p className="mt-2">
Die Zahlen für die Begründung stehen im{' '}
<Link href="/pflege/verlauf" className="underline">
Gesundheitsverlauf
</Link>
. Nimm ein Pflegetagebuch über zwei Wochen dazu — das überzeugt Gutachter mehr
als jede Schilderung im Gespräch.
</p>
</AlertDescription>
</Alert>
{begutachtungen.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold">Bisherige Begutachtungen</h3>
<ul className="space-y-2">
{begutachtungen.map((b) => (
<li
key={b.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3"
>
<span>
<span className="font-medium">{b.titel}</span>
{b.einrichtung && (
<span className="block text-sm text-muted-foreground">{b.einrichtung}</span>
)}
</span>
<span className="text-sm text-muted-foreground">{formatDatum(b.datum)}</span>
</li>
))}
</ul>
</div>
)}
{darfSchreiben && (
<div className="rounded-md border p-4">
{!offen ? (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<CalendarClock aria-hidden="true" />
{pruefungAm ? 'Prüftermin verschieben' : 'Prüftermin setzen'}
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<Zustandsmeldung zustand={zustand} />
<Feld
id="pruefungAm"
beschriftung="Pflegegrad überprüfen am"
erklaerung="Wird als Frist angelegt und erscheint im Fristenmodul. Ein halbes Jahr Abstand ist ein guter Erfahrungswert."
fehler={zustand.felder?.pruefungAm}
pflicht
>
<Input
name="pruefungAm"
type="date"
defaultValue={pruefungAm ?? vorschlagPruefungAm}
required
/>
</Feld>
<Feld
id="hoeherstufung-begruendung"
beschriftung="Worauf soll ich dann achten?"
fehler={zustand.felder?.begruendung}
>
<Textarea
name="begruendung"
rows={3}
maxLength={2000}
placeholder="z. B. Sauerstoffbedarf seit Februar auf 16 Stunden gestiegen, zweite Exazerbation im Jahr"
/>
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Termin setzen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
<Rechtshinweis rechtsgrundlage={PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG} />
</CardContent>
</Card>
)
}
@@ -0,0 +1,90 @@
import { BarChart3 } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { COPD_HINWEIS } from '@/lib/pflege/konstanten'
export type AuswertungsZeile = {
jahr: number
exazerbationen: number
krankenhausaufenthalte: number
krankenhaustage: number
abwesenheitstage: number
stuerze: number
satz: string
}
/**
* Die zwei Zahlen, die in den Jahresbericht und in jeden
* Hoeherstufungsantrag wandern.
*/
export function VerlaufAuswertung({
zeilen,
verschlechterung,
}: {
zeilen: AuswertungsZeile[]
verschlechterung: string | null
}) {
if (zeilen.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="size-5" aria-hidden="true" />
Auswertung je Jahr
</CardTitle>
<CardDescription>{COPD_HINWEIS}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Jahr</TableHead>
<TableHead className="text-right">Exazerbationen</TableHead>
<TableHead className="text-right">Krankenhaus</TableHead>
<TableHead className="text-right">Krankenhaustage</TableHead>
<TableHead className="text-right">Abwesenheitstage</TableHead>
<TableHead className="text-right">Stürze</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<TableRow key={zeile.jahr}>
<TableCell className="font-medium">{zeile.jahr}</TableCell>
<TableCell className="text-right tabular-nums">{zeile.exazerbationen}</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.krankenhausaufenthalte}
</TableCell>
<TableCell className="text-right tabular-nums">{zeile.krankenhaustage}</TableCell>
<TableCell className="text-right tabular-nums">{zeile.abwesenheitstage}</TableCell>
<TableCell className="text-right tabular-nums">{zeile.stuerze}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{verschlechterung && (
<p className="rounded-md border border-ampel-orange/40 bg-ampel-orange/10 p-3 text-sm">
{verschlechterung}
</p>
)}
<div>
<h3 className="mb-2 text-sm font-semibold">Für den Jahresbericht</h3>
<ul className="space-y-1.5">
{zeilen.map((zeile) => (
<li key={zeile.jahr} className="rounded-md border bg-muted/40 p-3 text-sm">
{zeile.satz}
</li>
))}
</ul>
<p className="mt-2 text-sm text-muted-foreground">
Bewusst ohne Diagnosen formuliert: Der Jahresbericht geht ans Betreuungsgericht,
und dort gehört nur hinein, was für die Betreuung erheblich ist.
</p>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,167 @@
'use client'
import type { GesundheitsArt } from '@prisma/client'
import Link from 'next/link'
import {
Activity,
BedDouble,
HeartPulse,
PersonStanding,
Pill,
Plus,
Stethoscope,
Syringe,
Trash2,
Wind,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { GESUNDHEITS_ART_BESCHRIFTUNG } from '@/lib/pflege/konstanten'
import { gesundheitsereignisLoeschen } from '@/server/actions/pflege'
import { formatDatum } from '@/lib/utils'
export type VerlaufZeile = {
id: string
datum: string
bisDatum: string | null
art: GesundheitsArt
titel: string
behandler: string | null
einrichtung: string | null
abwesenheitstage: number | null
beschreibung: string | null
}
const SYMBOL: Partial<Record<GesundheitsArt, typeof Activity>> = {
EXAZERBATION: Wind,
KRANKENHAUS: BedDouble,
SAUERSTOFFVERSORGUNG: Wind,
MEDIKATIONSAENDERUNG: Pill,
INHALATION: Wind,
STURZ: PersonStanding,
IMPFUNG: Syringe,
ARZTBESUCH: Stethoscope,
PFLEGEBEGUTACHTUNG: Activity,
}
const HERVORGEHOBEN: GesundheitsArt[] = ['EXAZERBATION', 'KRANKENHAUS', 'STURZ']
export function VerlaufZeitstrahl({
betreuteId,
ereignisse,
darfSchreiben,
}: {
betreuteId: string
ereignisse: VerlaufZeile[]
darfSchreiben: boolean
}) {
if (ereignisse.length === 0) {
return (
<Leerzustand
titel="Noch nichts erfasst"
beschreibung="Fang mit dem an, was zuletzt war: der letzte Krankenhausaufenthalt, die letzte Exazerbation, die aktuelle Sauerstoffversorgung. Rückwirkend erfassen ist mühsam, aber die Zahlen brauchst du für den Jahresbericht und für jede Höherstufung."
icon={HeartPulse}
aktion={
darfSchreiben && (
<Button asChild>
<Link href="/pflege/verlauf/neu">
<Plus aria-hidden="true" />
Ereignis erfassen
</Link>
</Button>
)
}
/>
)
}
// Nach Jahr gruppieren - so liest sich der Zeitstrahl wie eine Akte.
const jahre = new Map<number, VerlaufZeile[]>()
for (const ereignis of ereignisse) {
const jahr = new Date(ereignis.datum).getUTCFullYear()
const liste = jahre.get(jahr)
if (liste) liste.push(ereignis)
else jahre.set(jahr, [ereignis])
}
return (
<div className="space-y-8">
{[...jahre.entries()].map(([jahr, liste]) => (
<section key={jahr} className="space-y-3">
<h2 className="text-lg font-semibold">{jahr}</h2>
<ol className="space-y-3 border-l-2 border-border pl-5">
{liste.map((ereignis) => {
const Icon = SYMBOL[ereignis.art] ?? HeartPulse
const wichtig = HERVORGEHOBEN.includes(ereignis.art)
return (
<li key={ereignis.id} className="relative">
<span
className={
wichtig
? 'absolute -left-[1.6rem] top-4 size-3 rounded-full bg-ampel-orange ring-4 ring-background'
: 'absolute -left-[1.6rem] top-4 size-3 rounded-full bg-muted-foreground/40 ring-4 ring-background'
}
aria-hidden="true"
/>
<Card>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<Icon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1 space-y-1.5">
<CardTitle className="text-base">{ereignis.titel}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant={wichtig ? 'orange' : 'outline'}>
{GESUNDHEITS_ART_BESCHRIFTUNG[ereignis.art]}
</Badge>
<span>
{formatDatum(ereignis.datum)}
{ereignis.bisDatum && ` bis ${formatDatum(ereignis.bisDatum)}`}
</span>
{ereignis.abwesenheitstage !== null && (
<span>{ereignis.abwesenheitstage} Abwesenheitstage</span>
)}
{ereignis.behandler && <span>{ereignis.behandler}</span>}
{ereignis.einrichtung && <span>{ereignis.einrichtung}</span>}
</div>
</div>
{darfSchreiben && (
<form
action={async () => {
await gesundheitsereignisLoeschen(betreuteId, ereignis.id)
}}
>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`„${ereignis.titel}“ in den Papierkorb legen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
)}
</div>
</CardHeader>
{ereignis.beschreibung && (
<CardContent className="pt-0">
<p className="whitespace-pre-line text-sm text-muted-foreground">
{ereignis.beschreibung}
</p>
</CardContent>
)}
</Card>
</li>
)
})}
</ol>
</section>
))}
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import { useActionState } from 'react'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { ermitteltenWillenSpeichern } from '@/server/actions/pflege'
import type { Formularzustand } from '@/lib/validierung/substanz'
/**
* Der nach § 1821 BGB zu ermittelnde Wille.
*
* Der Erklaerungstext ist absichtlich lang: Diese Vorschrift hat die
* Betreuung 2023 vom Kopf auf die Fuesse gestellt, und wer sie nicht
* kennt, betreut immer noch nach dem alten "objektiven Wohl".
*/
export function WilleFormular({
betreuteId,
ermittelterWille,
darfSchreiben,
}: {
betreuteId: string
ermittelterWille: string | null
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
ermitteltenWillenSpeichern,
{},
)
if (!darfSchreiben) {
return ermittelterWille ? (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{ermittelterWille}
</p>
) : (
<p className="text-sm text-muted-foreground">Noch nichts hinterlegt.</p>
)
}
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<Zustandsmeldung zustand={zustand} />
<Feld
id="ermittelterWille"
beschriftung="Ermittelter Wille"
erklaerung="Wird verschlüsselt gespeichert. Halte fest, woher du es weißt — aus einem Gespräch, aus früheren Äußerungen, von Angehörigen — und wann. Ein Wille ohne Herkunft ist im Zweifel deine eigene Meinung."
fehler={zustand.felder?.ermittelterWille}
>
<Textarea
name="ermittelterWille"
defaultValue={ermittelterWille ?? ''}
rows={12}
maxLength={8000}
placeholder={
'Wohnen: Möchte nicht ins Krankenhaus, wenn es sich vermeiden lässt (Gespräch am 14.03.2026).\n' +
'Haus: Hängt am Elternhaus, will es nicht verkauft sehen — hat das mehrfach gesagt, zuletzt im Januar.\n' +
'Behandlung: Keine künstliche Beatmung (Patientenverfügung von 2019, im Gespräch bestätigt).\n' +
'Alltag: Besuch am Sonntagnachmittag, kein Fernsehen im Zimmer, eigene Bettwäsche.'
}
/>
</Feld>
<Absendeknopf>Speichern</Absendeknopf>
</form>
)
}
@@ -0,0 +1,273 @@
'use client'
import { useActionState } from 'react'
import { CheckCircle2, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
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 { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
import { kuendigungVermerken } from '@/server/actions/vertraege'
import { formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type AssistentDaten = {
schreiben: string
warnungen: string[]
genehmigung: {
stellen: boolean
frage: string
rechtsgrundlage: string
hinweis: string
}
versandnachweis: {
moeglichkeiten: string[]
hinweis: string
}
plan: {
berechenbar: boolean
naechsterTermin: string | null
zugangSpaetestensAm: string | null
spaetesterAbsendetag: string | null
tageBisAbsendetag: number | null
verpasst: boolean
hinweise: string[]
}
}
export function KuendigungsAssistent({
betreuteId,
vertragId,
daten,
heuteIso,
darfSchreiben,
}: {
betreuteId: string
vertragId: string
daten: AssistentDaten
heuteIso: string
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(kuendigungVermerken, {})
return (
<div className="space-y-6">
{daten.warnungen.length > 0 && (
<Alert variant="warnung">
<TriangleAlert aria-hidden="true" />
<AlertTitle>Vor dem Absenden klären</AlertTitle>
<AlertDescription>
<ul className="ml-4 list-disc space-y-1.5">
{daten.warnungen.map((warnung) => (
<li key={warnung}>{warnung}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scale className="size-5" aria-hidden="true" />
Frist geprüft
</CardTitle>
<CardDescription>
Zu diesem Termin kann als Nächstes gekündigt werden — und bis wann der Brief dafür
raus muss.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{daten.plan.berechenbar ? (
<dl className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Kündigung zum</dt>
<dd className="text-lg font-semibold">
{formatDatum(daten.plan.naechsterTermin)}
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold">
{formatDatum(daten.plan.zugangSpaetestensAm)}
</dd>
</div>
<div
className={
daten.plan.verpasst || (daten.plan.tageBisAbsendetag ?? 99) <= 30
? 'rounded-md border border-ampel-rot/40 bg-ampel-rot/5 p-3'
: 'rounded-md border p-3'
}
>
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
<dd className="text-lg font-semibold">
{formatDatum(daten.plan.spaetesterAbsendetag)}
</dd>
{daten.plan.tageBisAbsendetag !== null && (
<dd className="text-sm text-muted-foreground">
{daten.plan.tageBisAbsendetag < 0
? `vor ${Math.abs(daten.plan.tageBisAbsendetag)} Tagen`
: `in ${daten.plan.tageBisAbsendetag} Tagen`}
</dd>
)}
</div>
</dl>
) : (
<p className="text-sm text-muted-foreground">
Ohne Laufzeitbeginn und Kündigungsfrist lässt sich der Termin nicht berechnen.
</p>
)}
<ul className="space-y-1.5 text-sm text-muted-foreground">
{daten.plan.hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
</CardContent>
</Card>
<Card
className={
daten.genehmigung.stellen ? 'border-ampel-orange/50 bg-ampel-orange/5' : undefined
}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gavel className="size-5" aria-hidden="true" />
Genehmigungsfrage
</CardTitle>
<CardDescription>{daten.genehmigung.frage}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">{daten.genehmigung.hinweis}</p>
<Rechtshinweis rechtsgrundlage={daten.genehmigung.rechtsgrundlage} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Kündigungsschreiben</CardTitle>
<CardDescription>
Entwurf zum Kopieren und Bearbeiten. Die App verschickt nichts.
</CardDescription>
</CardHeader>
<CardContent>
<Textbaustein
text={daten.schreiben}
beschriftung="Kündigungsschreiben"
hoehe="min-h-[36rem]"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileWarning className="size-5" aria-hidden="true" />
Versandnachweis
</CardTitle>
<CardDescription>{daten.versandnachweis.hinweis}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="ml-4 list-disc space-y-1 text-sm">
{daten.versandnachweis.moeglichkeiten.map((moeglichkeit) => (
<li key={moeglichkeit}>{moeglichkeit}</li>
))}
</ul>
{darfSchreiben && (
<form action={aktion} className="space-y-4 rounded-md border p-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="k-gekuendigtAm"
beschriftung="Versandt am"
fehler={zustand.felder?.gekuendigtAm}
pflicht
>
<Input name="gekuendigtAm" type="date" defaultValue={heuteIso} required />
</Feld>
<Feld
id="k-kuendigungZum"
beschriftung="Gekündigt zum"
erklaerung="Der Termin aus der Berechnung oben."
fehler={zustand.felder?.kuendigungZum}
>
<Input
name="kuendigungZum"
type="date"
defaultValue={daten.plan.naechsterTermin ?? ''}
/>
</Feld>
<Feld
id="k-versandart"
beschriftung="Versandart"
fehler={zustand.felder?.versandart}
pflicht
className="sm:col-span-2"
>
<Select name="versandart" defaultValue={daten.versandnachweis.moeglichkeiten[0]}>
{daten.versandnachweis.moeglichkeiten.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</Select>
</Feld>
</div>
<label className="flex items-start gap-2.5">
<Checkbox name="nachweisVorhanden" className="mt-0.5" />
<span className="text-sm">
Der Versandnachweis liegt vor und ist als Dokument hochgeladen.
{zustand.felder?.nachweisVorhanden && (
<span className="mt-1 block font-medium text-destructive">
{zustand.felder.nachweisVorhanden}
</span>
)}
</span>
</label>
<label className="flex items-start gap-2.5">
<Checkbox name="genehmigungGeklaert" className="mt-0.5" />
<span className="text-sm">
Die Genehmigungsfrage ist mit dem Rechtspfleger geklärt (§ 1850 BGB).
</span>
</label>
<Feld id="k-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea
name="bemerkung"
rows={2}
maxLength={2000}
placeholder="z. B. Einlieferungsnummer des Einschreibens"
/>
</Feld>
<Absendeknopf>
<span className="flex items-center gap-2">
<CheckCircle2 className="size-4" aria-hidden="true" />
Kündigung als versandt vermerken
</span>
</Absendeknopf>
<p className="text-sm text-muted-foreground">
Setzt den Status auf „gekündigt“ und legt eine Wiedervorlage an, damit die
Bestätigung der Gesellschaft nicht vergessen wird.
</p>
</form>
)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,67 @@
import Link from 'next/link'
import { CalendarClock, TriangleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatDatum } from '@/lib/utils'
export type Kuendigungswarnung = {
vertragId: string
bezeichnung: string
absendetag: string | null
tageBisAbsendetag: number | null
verpasst: boolean
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
/**
* Die Warnkachel oben auf der Vertragsuebersicht.
*
* Sie zeigt nur, was in den naechsten 30 Tagen raus muss. Eine Liste, die
* alles zeigt, wird nicht gelesen - und dann geht genau die eine Frist
* unter, auf die es ankam.
*/
export function KuendigungsUebersicht({ warnungen }: { warnungen: Kuendigungswarnung[] }) {
if (warnungen.length === 0) return null
return (
<Card className="border-ampel-orange/40 bg-ampel-orange/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TriangleAlert className="size-5 text-ampel-orange" aria-hidden="true" />
Kündigungsfrist rückt näher
</CardTitle>
<CardDescription>
Eine Kündigung wird erst mit dem Zugang wirksam. Der Absendetag ist deshalb früher
als der Fristablauf — hier ist der Postlauf schon abgezogen.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{warnungen.map((warnung) => (
<li
key={warnung.vertragId}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-background p-3"
>
<Link
href={`/vertraege/${warnung.vertragId}`}
className="font-medium hover:underline"
>
{warnung.bezeichnung}
</Link>
<span className="flex items-center gap-2">
{warnung.absendetag && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" aria-hidden="true" />
Brief bis {formatDatum(warnung.absendetag)}
</span>
)}
<Badge variant={warnung.variante}>{warnung.text}</Badge>
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}
+243
View File
@@ -0,0 +1,243 @@
'use client'
import { useActionState, useState } from 'react'
import { Coins, Mail, Plus } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
import { rueckkaufswertAnfordern, rueckkaufswertErfassen } from '@/server/actions/vertraege'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type RueckkaufswertZeile = {
id: string
stichtag: string
wertCent: number
wertquelle: string
bemerkung: string | null
}
export function Rueckkaufswert({
betreuteId,
vertragId,
werte,
angefragtAm,
erinnerungFaellig,
tageSeitAnfrage,
anschreiben,
stichtagVorschlag,
darfSchreiben,
}: {
betreuteId: string
vertragId: string
werte: RueckkaufswertZeile[]
angefragtAm: string | null
erinnerungFaellig: boolean
tageSeitAnfrage: number | null
anschreiben: string
stichtagVorschlag: string
darfSchreiben: boolean
}) {
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
rueckkaufswertErfassen,
{},
)
const [anfrage, anfrageAktion] = useActionState<Formularzustand, FormData>(
rueckkaufswertAnfordern,
{},
)
const [briefOffen, setBriefOffen] = useState(false)
const [erfassenOffen, setErfassenOffen] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Coins className="size-5" aria-hidden="true" />
Rückkaufswert
</CardTitle>
<CardDescription>
Der Bezirk verlangt zu jedem Versicherungsvertrag den Rückkaufswert zu einem
Stichtag — schriftlich von der Gesellschaft, nicht geschätzt. Die Antwort dauert
erfahrungsgemäß drei bis sechs Wochen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{angefragtAm && (
<Alert variant={erinnerungFaellig ? 'warnung' : 'hinweis'}>
<Mail aria-hidden="true" />
<AlertTitle>
Angefragt am {formatDatum(angefragtAm)}
{tageSeitAnfrage !== null && ` — vor ${tageSeitAnfrage} Tagen`}
</AlertTitle>
<AlertDescription>
{erinnerungFaellig
? 'Die vier Wochen sind um. Ruf an und halte das Gespräch im Kontaktprotokoll fest — schriftlich nachfassen dauert wieder vier Wochen.'
: 'Die Wiedervorlage steht in den Fristen. Kommt die Antwort früher, hak die Frist dort ab.'}
</AlertDescription>
</Alert>
)}
{werte.length === 0 ? (
<Leerzustand
titel="Noch kein Rückkaufswert erfasst"
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
icon={Coins}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setBriefOffen(true)}>
<Mail aria-hidden="true" />
Anschreiben erzeugen
</Button>
)
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead className="text-right">Rückkaufswert</TableHead>
<TableHead>Quelle</TableHead>
<TableHead>Bemerkung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{werte.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell className="whitespace-nowrap">{formatDatum(zeile.stichtag)}</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatCent(zeile.wertCent)}
</TableCell>
<TableCell>{zeile.wertquelle}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{zeile.bemerkung ?? '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{darfSchreiben && (
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setBriefOffen((o) => !o)}>
<Mail aria-hidden="true" />
{briefOffen ? 'Anschreiben schließen' : 'Rückkaufswert anfordern'}
</Button>
<Button type="button" variant="outline" onClick={() => setErfassenOffen((o) => !o)}>
<Plus aria-hidden="true" />
{erfassenOffen ? 'Eingabe schließen' : 'Mitgeteilten Wert eintragen'}
</Button>
</div>
)}
{darfSchreiben && briefOffen && (
<div className="space-y-4 rounded-md border p-4">
<Textbaustein
text={anschreiben}
beschriftung="Anschreiben an die Gesellschaft"
hoehe="min-h-[32rem]"
/>
<form action={anfrageAktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<Zustandsmeldung zustand={anfrage} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="anfrage-stichtag"
beschriftung="Stichtag der Anfrage"
erklaerung="Der Tag, zu dem der Wert mitgeteilt werden soll. Für die Sozialhilfe meist der Tag der Antragstellung."
fehler={anfrage.felder?.stichtag}
pflicht
>
<Input
name="stichtag"
type="date"
defaultValue={stichtagVorschlag}
required
/>
</Feld>
</div>
<Absendeknopf>Anfrage vermerken und Wiedervorlage anlegen</Absendeknopf>
<p className="text-sm text-muted-foreground">
Setzt das Datum der Anfrage am Vertrag und legt eine Wiedervorlage in vier
Wochen an. Den Brief selbst schickst du von Hand ab — die App verschickt nichts.
</p>
</form>
</div>
)}
{darfSchreiben && erfassenOffen && (
<form action={erfassenAktion} className="space-y-4 rounded-md border p-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<Zustandsmeldung zustand={erfassen} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="rkw-stichtag"
beschriftung="Stichtag"
fehler={erfassen.felder?.stichtag}
pflicht
>
<Input name="stichtag" type="date" required />
</Feld>
<Feld
id="rkw-wert"
beschriftung="Rückkaufswert"
fehler={erfassen.felder?.wert}
pflicht
>
<Input name="wert" inputMode="decimal" placeholder="4.812,55" required />
</Feld>
<Feld
id="rkw-quelle"
beschriftung="Quelle"
erklaerung="Woher stammt die Zahl? Eine telefonische Auskunft ist besser als nichts, aber kein Nachweis."
fehler={erfassen.felder?.wertquelle}
pflicht
className="sm:col-span-2"
>
<Select name="wertquelle" defaultValue={WERTQUELLEN_VERTRAG[0]}>
{WERTQUELLEN_VERTRAG.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="rkw-bemerkung"
beschriftung="Bemerkung"
erklaerung="z. B. die beitragsfreie Versicherungssumme, wenn sie mitgeteilt wurde."
fehler={erfassen.felder?.bemerkung}
>
<Textarea name="bemerkung" rows={2} maxLength={1000} />
</Feld>
<Absendeknopf>Wert aufnehmen</Absendeknopf>
</form>
)}
</CardContent>
</Card>
)
}
+237 -248
View File
@@ -1,248 +1,237 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
laufzeitJahre: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
laufzeitJahre: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
>
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="laufzeitJahre"
beschriftung="Laufzeit in Jahren"
erklaerung="Um wie viel verlängert sich der Vertrag? Leer lassen für den Regelfall von einem Jahr."
fehler={zustand.felder?.laufzeitJahre}
>
<Input name="laufzeitJahre" inputMode="numeric" defaultValue={werte.laufzeitJahre} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
>
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
+171 -172
View File
@@ -1,172 +1,171 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
laufzeitJahre: optionaleZahl(1, 99, 'Bitte die Laufzeit in ganzen Jahren angeben.'),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
+52
View File
@@ -0,0 +1,52 @@
import type { Briefkopf } from '@/lib/vertraege/anschreiben'
/**
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
*
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert,
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin.
*/
export type BriefkopfQuelle = {
betreute: {
vorname: string
nachname: string
anrede: string | null
geburtsdatum: Date
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
}
benutzer: { name: string }
}
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
const b = quelle.betreute
const anschrift = [
b.heimName,
b.heimStrasse,
[b.heimPlz, b.heimOrt].filter(Boolean).join(' ') || null,
]
.filter(Boolean)
.join('\n')
return {
betreuerName: quelle.benutzer.name,
betreuerStrasse: null,
betreuerPlz: null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer.
betreuerOrt: null,
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
betreuteGeburtsdatum: b.geburtsdatum,
betreuteAnschrift: anschrift || null,
betreuungsgericht: b.betreuungsgericht,
aktenzeichenGericht: b.aktenzeichenGericht,
bestellungAm: b.bestellungAm,
}
}
+227 -207
View File
@@ -1,207 +1,227 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Kuendigungsfrist-Rechner.
*
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr.
*
* Deshalb rechnet diese Datei drei Daten aus:
*
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer.
*
* Reine Logik, keine Datenbank.
*/
/**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da.
*/
export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/
export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date
}
export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[]
}
const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false,
naechsterTermin: null,
zugangSpaetestensAm: null,
spaetesterAbsendetag: null,
tageBisAbsendetag: null,
vorwarnung: false,
verpasst: false,
hinweise: [],
}
/**
* Berechnet den naechstmoeglichen Kuendigungstermin.
*
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche.
*/
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) {
hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.',
)
}
const fristMonate = frist ?? 0
const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
)
}
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null
if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
}
if (!termin) {
return {
...LEERER_PLAN,
hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
],
}
}
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1
}
const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) {
hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`,
)
} else if (durchlaeufe > 0) {
hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
)
}
hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`,
)
return {
berechenbar: true,
naechsterTermin: termin,
zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst,
hinweise,
}
}
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
}
/**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben.
*/
export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' }
}
const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
}
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
}
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Kuendigungsfrist-Rechner.
*
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr.
*
* Deshalb rechnet diese Datei drei Daten aus:
*
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer.
*
* Reine Logik, keine Datenbank.
*/
/**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da.
*/
export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/
export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date
}
export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[]
}
const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false,
naechsterTermin: null,
zugangSpaetestensAm: null,
spaetesterAbsendetag: null,
tageBisAbsendetag: null,
vorwarnung: false,
verpasst: false,
hinweise: [],
}
/**
* Berechnet den naechstmoeglichen Kuendigungstermin.
*
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche.
*/
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) {
hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.',
)
}
const fristMonate = frist ?? 0
const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
)
}
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null
if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
}
if (!termin) {
return {
...LEERER_PLAN,
hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
],
}
}
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1
}
const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) {
hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`,
)
} else if (durchlaeufe > 0) {
hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
)
}
hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`,
)
return {
berechenbar: true,
naechsterTermin: termin,
zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst,
hinweise,
}
}
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
}
/**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben.
*/
export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' }
}
const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
}
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
}
/**
* Die Vertragsdauer aus Beginn und vereinbartem Ende.
*
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
* Spanne unter einem halben Jahr, bleibt es beim Standard.
*/
export function laufzeitAusVertrag(
beginnAm: Date | null,
endeAm: Date | null,
): number | null {
if (!beginnAm || !endeAm) return null
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
return jahre >= 1 ? jahre : null
}