Zeilenenden der 24 verbliebenen Dateien normalisieren

Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
This commit is contained in:
Florian Wilde
2026-08-23 23:37:02 +02:00
parent 6a0db51e3b
commit 1ed19eb76e
24 changed files with 7448 additions and 7448 deletions
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from 'next/link'
import {
Archive,
CalendarClock,
FolderArchive,
Paperclip,
ScrollText,
} from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [
'anfangsbericht',
'jahresbericht',
'vermoegensverzeichnis',
'vermoegensuebersicht',
'rechnungslegung',
]
const WEGE = [
{
href: '/berichte/jahresbericht',
titel: 'Jahresbericht',
beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText,
},
{
href: '/berichte/belegmappe',
titel: 'Belegmappe',
beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive,
},
{
href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger',
beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip,
},
{
href: '/berichte/export',
titel: 'Vollexport',
beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive,
},
]
/**
* Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
*
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/
export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([
db.frist.findMany({
where: {
betreuteId,
status: 'OFFEN',
...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel },
})),
},
orderBy: { datum: 'asc' },
take: 10,
}),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
])
return (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/>
<section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4">
Fristen
</Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p>
) : (
<ul className="divide-y rounded-md border">
{fristen.map((frist) => {
const tage = tageBis(frist.datum)
const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge>
</li>
)
})}
</ul>
)}
</section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => {
const Icon = weg.icon
return (
<Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0">
<CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel}
</Link>
</CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div>
</CardHeader>
</Card>
)
})}
</section>
<section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section>
<HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
import Link from 'next/link'
import {
Archive,
CalendarClock,
FolderArchive,
Paperclip,
ScrollText,
} from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [
'anfangsbericht',
'jahresbericht',
'vermoegensverzeichnis',
'vermoegensuebersicht',
'rechnungslegung',
]
const WEGE = [
{
href: '/berichte/jahresbericht',
titel: 'Jahresbericht',
beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText,
},
{
href: '/berichte/belegmappe',
titel: 'Belegmappe',
beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive,
},
{
href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger',
beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip,
},
{
href: '/berichte/export',
titel: 'Vollexport',
beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive,
},
]
/**
* Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
*
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/
export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([
db.frist.findMany({
where: {
betreuteId,
status: 'OFFEN',
...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel },
})),
},
orderBy: { datum: 'asc' },
take: 10,
}),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
])
return (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/>
<section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4">
Fristen
</Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p>
) : (
<ul className="divide-y rounded-md border">
{fristen.map((frist) => {
const tage = tageBis(frist.datum)
const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge>
</li>
)
})}
</ul>
)}
</section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => {
const Icon = weg.icon
return (
<Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0">
<CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel}
</Link>
</CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div>
</CardHeader>
</Card>
)
})}
</section>
<section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section>
<HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
@@ -1,149 +1,149 @@
import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag'
import {
fristAmpel,
rechtsbehelfsfristBerechnen,
zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Bescheid-Prüfung.
*
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist.
*/
export default async function BescheidSeite({
params,
}: {
params: Promise<{ antragId: string; bescheidId: string }>
}) {
const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
})
if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
})
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return (
<div className="space-y-8">
<SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={
<>
{bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)}
</>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' },
]}
/>
<BescheidPruefung
bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
}
darfSchreiben={darfSchreiben}
rechenweg={{
berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null,
rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde)
: null,
verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise,
}}
/>
{offen.notizen && (
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent>
</Card>
)}
{darfSchreiben && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular
antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{
id: bescheid.id,
art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '',
notizen: offen.notizen ?? '',
}}
/>
<div className="border-t pt-6">
<AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline"
size="default"
>
<Trash2 aria-hidden="true" />
Bescheid löschen
</AktionMitRueckfrage>
</div>
</section>
)}
</div>
)
}
import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag'
import {
fristAmpel,
rechtsbehelfsfristBerechnen,
zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Bescheid-Prüfung.
*
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist.
*/
export default async function BescheidSeite({
params,
}: {
params: Promise<{ antragId: string; bescheidId: string }>
}) {
const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
})
if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
})
const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return (
<div className="space-y-8">
<SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={
<>
{bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)}
</>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' },
]}
/>
<BescheidPruefung
bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
}
darfSchreiben={darfSchreiben}
rechenweg={{
berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null,
rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde)
: null,
verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise,
}}
/>
{offen.notizen && (
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent>
</Card>
)}
{darfSchreiben && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular
antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{
id: bescheid.id,
art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '',
notizen: offen.notizen ?? '',
}}
/>
<div className="border-t pt-6">
<AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline"
size="default"
>
<Trash2 aria-hidden="true" />
Bescheid löschen
</AktionMitRueckfrage>
</div>
</section>
)}
</div>
)
}
+232 -232
View File
@@ -1,232 +1,232 @@
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic'
/**
* Vermögens-Bewertungsansicht.
*
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/
export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' },
}),
db.vertrag.findMany({
where: {
betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht,
},
orderBy: { bezeichnung: 'asc' },
}),
])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
)
const zeilen: ListenPosten[] = [
...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0]
return {
id: p.id,
bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const,
art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null,
}
}),
...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => {
const geoeffnet = oeffnen('Vertrag', v)
return {
id: v.id,
bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const,
art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null,
wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft,
}
}),
]
const uebersicht = uebersichtBilden(
zeilen.map((z) => ({
id: z.id,
bezeichnung: z.bezeichnung,
herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null,
begruendung: z.begruendung,
href: '#',
})),
)
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return (
<div className="space-y-8">
<SeitenKopf
titel="Vermögen bewerten"
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' },
]}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground"
/>
</div>
<Card>
<CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span>
<span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)}
</span>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
<span
className={`text-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`}
>
{formatCent(vergleich.ueberFreibetragCent)}
</span>
</div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent>
</Card>
{uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>
{uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle>
<AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen.
</AlertDescription>
</Alert>
)}
{uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription>
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
</AlertDescription>
</Alert>
)}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div>
)
}
function Summenkachel({
titel,
betrag,
anzahl,
ohneWert,
farbe,
}: {
titel: string
betrag: bigint
anzahl: number
ohneWert: number
farbe: string
}) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p>
</CardContent>
</Card>
)
}
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic'
/**
* Vermögens-Bewertungsansicht.
*
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/
export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' },
}),
db.vertrag.findMany({
where: {
betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht,
},
orderBy: { bezeichnung: 'asc' },
}),
])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
)
const zeilen: ListenPosten[] = [
...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0]
return {
id: p.id,
bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const,
art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null,
}
}),
...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => {
const geoeffnet = oeffnen('Vertrag', v)
return {
id: v.id,
bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const,
art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null,
wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft,
}
}),
]
const uebersicht = uebersichtBilden(
zeilen.map((z) => ({
id: z.id,
bezeichnung: z.bezeichnung,
herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null,
begruendung: z.begruendung,
href: '#',
})),
)
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return (
<div className="space-y-8">
<SeitenKopf
titel="Vermögen bewerten"
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' },
]}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange"
/>
<Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground"
/>
</div>
<Card>
<CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span>
<span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)}
</span>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
<span
className={`text-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`}
>
{formatCent(vergleich.ueberFreibetragCent)}
</span>
</div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent>
</Card>
{uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>
{uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle>
<AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen.
</AlertDescription>
</Alert>
)}
{uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription>
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
</AlertDescription>
</Alert>
)}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div>
)
}
function Summenkachel({
titel,
betrag,
anzahl,
ohneWert,
farbe,
}: {
titel: string
betrag: bigint
anzahl: number
ohneWert: number
farbe: string
}) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p>
</CardContent>
</Card>
)
}
@@ -1,77 +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>
)
}
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>
)
}
+96 -96
View File
@@ -1,96 +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>
)
}
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>
)
}
+275 -275
View File
@@ -1,275 +1,275 @@
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,
wertHistorie,
} from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
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: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: {
where: 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 = wertHistorie(vertrag.vermoegensposten)
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)}
alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
}
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>
)
}
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,
wertHistorie,
} from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
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: {
// Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
vermoegensposten: {
where: 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 = wertHistorie(vertrag.vermoegensposten)
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)}
alterHinweis={
wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
}
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>
)
}
+438 -438
View File
@@ -1,438 +1,438 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
+310 -310
View File
@@ -1,310 +1,310 @@
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
hinweisPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/
hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse}
>
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
hinweisPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/
hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse}
>
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
+237 -237
View File
@@ -1,237 +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
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>
)
}
'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>
)
}
+213 -213
View File
@@ -1,213 +1,213 @@
import 'server-only'
import {
AlignmentType,
BorderStyle,
Document,
Footer,
Header,
HeadingLevel,
PageNumber,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/**
* DOCX-Ausgabe.
*
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
*
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung.
*
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/
export type DocxKopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({
text,
bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
})
}
function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
]
if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
)
}
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen
}
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 },
}),
new Paragraph({
children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 },
}),
new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}),
]
}
/**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [
new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 },
}),
...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
),
new Paragraph({
children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 },
}),
]
for (const abschnitt of abschnitte) {
if (abschnitt.titel) {
inhalt.push(
new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 },
}),
)
}
for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•')
inhalt.push(
new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 },
}),
)
}
}
inhalt.push(...unterschriftenblock(kopf))
return new Document({
creator: kopf.erstelltVon,
title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true },
),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(' von ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8,
grau: true,
}),
],
}),
],
}),
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
import 'server-only'
import {
AlignmentType,
BorderStyle,
Document,
Footer,
Header,
HeadingLevel,
PageNumber,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/**
* DOCX-Ausgabe.
*
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
*
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung.
*
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/
export type DocxKopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({
text,
bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
})
}
function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
]
if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
)
}
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen
}
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 },
}),
new Paragraph({
children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 },
}),
new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}),
]
}
/**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [
new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 },
}),
...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
),
new Paragraph({
children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 },
}),
]
for (const abschnitt of abschnitte) {
if (abschnitt.titel) {
inhalt.push(
new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 },
}),
)
}
for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•')
inhalt.push(
new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 },
}),
)
}
}
inhalt.push(...unterschriftenblock(kopf))
return new Document({
creator: kopf.erstelltVon,
title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true },
),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(' von ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8,
grau: true,
}),
],
}),
],
}),
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
+214 -214
View File
@@ -1,214 +1,214 @@
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
+237 -237
View File
@@ -1,237 +1,237 @@
/**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
*
* ACHTUNG - EINZIGE STELLE.
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden.
*
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft.
*/
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01'
/**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein.
*/
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// -----------------------------------------------------------------------------
/**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung).
*
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt.
*/
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1.
*
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
*
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/
export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/**
* Bekleidungspauschale.
*
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine.
*/
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
// -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz).
*
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
*
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n,
3: 131_900n,
4: 185_500n,
5: 209_600n,
}
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI',
}
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
//
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen.
//
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub)
// -----------------------------------------------------------------------------
/**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
*
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
*
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung.
*/
export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// -----------------------------------------------------------------------------
// Vermögen, § 90 SGB XII
// -----------------------------------------------------------------------------
/**
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
*
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre.
*/
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// -----------------------------------------------------------------------------
// Rechtsbehelf
// -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X).
*/
export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
/**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG).
*
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen.
*/
export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %.
*
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
*
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
*/
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
*/
export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
/**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
*
* ACHTUNG - EINZIGE STELLE.
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden.
*
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft.
*/
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01'
/**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein.
*/
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// -----------------------------------------------------------------------------
/**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung).
*
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt.
*/
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1.
*
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
*
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/
export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/**
* Bekleidungspauschale.
*
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine.
*/
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
// -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz).
*
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
*
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n,
3: 131_900n,
4: 185_500n,
5: 209_600n,
}
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI',
}
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
//
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen.
//
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub)
// -----------------------------------------------------------------------------
/**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
*
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
*
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung.
*/
export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// -----------------------------------------------------------------------------
// Vermögen, § 90 SGB XII
// -----------------------------------------------------------------------------
/**
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
*
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre.
*/
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// -----------------------------------------------------------------------------
// Rechtsbehelf
// -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X).
*/
export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
/**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG).
*
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen.
*/
export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %.
*
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
*
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
*/
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
*/
export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
+437 -437
View File
@@ -1,437 +1,437 @@
import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
+171 -171
View File
@@ -1,171 +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.',
),
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>
+227 -227
View File
@@ -1,227 +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` }
}
/**
* 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
}
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
}
File diff suppressed because it is too large Load Diff
+385 -385
View File
@@ -1,385 +1,385 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { passwortHashen } from '@/lib/crypto/hash'
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import {
benutzerAktivSchema,
benutzerAnlegenSchema,
darfBenutzerVerwalten,
darfFreigabenVergeben,
freigabeAenderung,
freigabenNormalisieren,
freigabenSchema,
letzterAdminGeschuetzt,
rolleAendernSchema,
sitzungenBeendenSchema,
} from '@/lib/validierung/freigaben'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
* wem wann was freigegeben hat.
*/
export type Aktionszustand = {
fehler?: string
felder?: Record<string, string>
erfolg?: string
warnung?: string
}
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler) {
const schluessel = String(problem.path[0] ?? 'formular')
if (!felder[schluessel]) felder[schluessel] = problem.message
}
return felder
}
async function administratorSein(weiter: string) {
const benutzer = await erfordereBenutzer(weiter)
if (!darfBenutzerVerwalten(benutzer.rolle)) {
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
}
await erfordereFrischeAnmeldung(benutzer, weiter)
return benutzer
}
async function anzahlAktiverAdmins(): Promise<number> {
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
}
// -----------------------------------------------------------------------------
// Benutzer
// -----------------------------------------------------------------------------
export async function benutzerAnlegen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAnlegenSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
rolle: formular.get('rolle'),
passwort: formular.get('passwort'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
if (vorhanden) {
return {
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
felder: { email: 'Bereits vergeben.' },
}
}
const neu = await db.user.create({
data: {
name: gelesen.data.name,
email: gelesen.data.email,
rolle: gelesen.data.rolle,
passwortHash: await passwortHashen(gelesen.data.passwort),
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: neu.id,
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg:
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
`werden – ohne ihn kommt niemand an Fachdaten.`,
}
}
export async function rolleAendern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = rolleAendernSchema.safeParse({
userId: formular.get('userId'),
rolle: formular.get('rolle'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: gelesen.data.rolle,
zielBleibtAktiv: ziel.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
await db.user.update({
where: { id: ziel.id },
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'ROLLE_GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
}
}
export async function benutzerAktivSetzen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAktivSchema.safeParse({
userId: formular.get('userId'),
aktiv: formular.get('aktiv') === 'ja',
})
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.id === admin.id && !gelesen.data.aktiv) {
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
}
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: ziel.rolle,
zielBleibtAktiv: gelesen.data.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
await db.user.update({
where: { id: ziel.id },
data: {
aktiv: gelesen.data.aktiv,
// Wer abgeschaltet wird, ist sofort abgemeldet.
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: gelesen.data.aktiv
? `${ziel.name} kann sich wieder anmelden.`
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
}
}
export async function sitzungenBeenden(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
await db.user.update({
where: { id: ziel.id },
data: { sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: {
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
},
})
revalidatePath('/einstellungen/benutzer')
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
}
// -----------------------------------------------------------------------------
// Freigaben je betreuter Person
// -----------------------------------------------------------------------------
export async function freigabenSpeichern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = freigabenSchema.safeParse({
userId: formular.get('userId'),
betreuteId: formular.get('betreuteId'),
rolle: formular.get('rolle'),
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const { userId, betreuteId, rolle } = gelesen.data
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: freigaben },
)
await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: freigaben },
})
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
// weiterbenutzen koennen.
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
}
const beschreibung = [
aenderung.rolleGeaendert
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
: null,
aenderung.hinzugefuegt.length > 0
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
aenderung.entfernt.length > 0
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
]
.filter(Boolean)
.join('; ')
await protokollieren({
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
},
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
const warnung =
aenderung.sensibelHinzugefuegt.length > 0
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
: undefined
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
}
export async function zugangEntziehen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const userId = String(formular.get('userId') ?? '')
const betreuteId = String(formular.get('betreuteId') ?? '')
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
if (userId === benutzer.id) {
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
}
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
await protokollieren({
aktion: 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
}
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { passwortHashen } from '@/lib/crypto/hash'
import { erfordereBenutzer, erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { MODUL_BESCHRIFTUNG, ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import {
benutzerAktivSchema,
benutzerAnlegenSchema,
darfBenutzerVerwalten,
darfFreigabenVergeben,
freigabeAenderung,
freigabenNormalisieren,
freigabenSchema,
letzterAdminGeschuetzt,
rolleAendernSchema,
sitzungenBeendenSchema,
} from '@/lib/validierung/freigaben'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Alles hier ist eine sensible Aktion: Wer Rollen aendert oder einem
* Mitleser die Gesundheitsdaten freigibt, verschiebt die Grenze, wer was
* ueber einen kranken Menschen erfahren darf. Deshalb gilt durchgehend:
* frische Anmeldung, Administratorrecht bzw. Betreuerrecht fuer die
* betroffene Person, und ein Audit-Eintrag, der spaeter beantwortet, wer
* wem wann was freigegeben hat.
*/
export type Aktionszustand = {
fehler?: string
felder?: Record<string, string>
erfolg?: string
warnung?: string
}
function felderAusZod(fehler: { path: (string | number)[]; message: string }[]): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler) {
const schluessel = String(problem.path[0] ?? 'formular')
if (!felder[schluessel]) felder[schluessel] = problem.message
}
return felder
}
async function administratorSein(weiter: string) {
const benutzer = await erfordereBenutzer(weiter)
if (!darfBenutzerVerwalten(benutzer.rolle)) {
throw new Error('Die Benutzerverwaltung ist Administratoren vorbehalten.')
}
await erfordereFrischeAnmeldung(benutzer, weiter)
return benutzer
}
async function anzahlAktiverAdmins(): Promise<number> {
return db.user.count({ where: { rolle: 'ADMIN', aktiv: true, ...nichtGeloescht } })
}
// -----------------------------------------------------------------------------
// Benutzer
// -----------------------------------------------------------------------------
export async function benutzerAnlegen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAnlegenSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
rolle: formular.get('rolle'),
passwort: formular.get('passwort'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const vorhanden = await db.user.findUnique({ where: { email: gelesen.data.email } })
if (vorhanden) {
return {
fehler: 'Unter dieser E-Mail-Adresse gibt es schon ein Konto.',
felder: { email: 'Bereits vergeben.' },
}
}
const neu = await db.user.create({
data: {
name: gelesen.data.name,
email: gelesen.data.email,
rolle: gelesen.data.rolle,
passwortHash: await passwortHashen(gelesen.data.passwort),
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: neu.id,
bezeichnung: `${neu.name} <${neu.email}> als ${ROLLEN_BESCHRIFTUNG[neu.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg:
`${neu.name} ist angelegt. Beim ersten Anmelden muss der zweite Faktor eingerichtet ` +
`werden – ohne ihn kommt niemand an Fachdaten.`,
}
}
export async function rolleAendern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = rolleAendernSchema.safeParse({
userId: formular.get('userId'),
rolle: formular.get('rolle'),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.rolle === gelesen.data.rolle) return { erfolg: 'Die Rolle war bereits so gesetzt.' }
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: gelesen.data.rolle,
zielBleibtAktiv: ziel.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
// Eine Rollenaenderung beendet alle laufenden Sitzungen des Benutzers -
// sonst behielte er bis zum Ablauf des Tokens seine alten Rechte.
await db.user.update({
where: { id: ziel.id },
data: { rolle: gelesen.data.rolle, sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'ROLLE_GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name}: ${ROLLEN_BESCHRIFTUNG[ziel.rolle]} → ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { rolle: { alt: ziel.rolle, neu: gelesen.data.rolle } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: `${ziel.name} ist jetzt ${ROLLEN_BESCHRIFTUNG[gelesen.data.rolle]}. Bestehende Anmeldungen wurden beendet.`,
}
}
export async function benutzerAktivSetzen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = benutzerAktivSchema.safeParse({
userId: formular.get('userId'),
aktiv: formular.get('aktiv') === 'ja',
})
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
if (ziel.id === admin.id && !gelesen.data.aktiv) {
return { fehler: 'Du kannst dich nicht selbst abschalten.' }
}
const schutz = letzterAdminGeschuetzt({
zielRolleVorher: ziel.rolle,
zielRolleNachher: ziel.rolle,
zielBleibtAktiv: gelesen.data.aktiv,
anzahlAktiverAdmins: await anzahlAktiverAdmins(),
})
if (schutz) return { fehler: schutz }
await db.user.update({
where: { id: ziel.id },
data: {
aktiv: gelesen.data.aktiv,
// Wer abgeschaltet wird, ist sofort abgemeldet.
sitzungsGeneration: gelesen.data.aktiv ? undefined : { increment: 1 },
},
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `${ziel.name} ${gelesen.data.aktiv ? 'wieder aktiviert' : 'deaktiviert'}`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: { aktiv: { alt: ziel.aktiv, neu: gelesen.data.aktiv } },
})
revalidatePath('/einstellungen/benutzer')
return {
erfolg: gelesen.data.aktiv
? `${ziel.name} kann sich wieder anmelden.`
: `${ziel.name} ist deaktiviert und sofort abgemeldet.`,
}
}
export async function sitzungenBeenden(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = sitzungenBeendenSchema.safeParse({ userId: formular.get('userId') })
if (!gelesen.success) return { fehler: 'Bitte prüfe deine Eingaben.' }
const admin = await administratorSein('/einstellungen/benutzer')
const ziel = await db.user.findFirst({ where: { id: gelesen.data.userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
await db.user.update({
where: { id: ziel.id },
data: { sitzungsGeneration: { increment: 1 } },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'User',
entitaetId: ziel.id,
bezeichnung: `Alle Sitzungen von ${ziel.name} beendet`,
userId: admin.id,
userEmail: admin.email,
userName: admin.name,
aenderung: {
sitzungsGeneration: { alt: ziel.sitzungsGeneration, neu: ziel.sitzungsGeneration + 1 },
},
})
revalidatePath('/einstellungen/benutzer')
return { erfolg: `Alle Geräte von ${ziel.name} sind abgemeldet.` }
}
// -----------------------------------------------------------------------------
// Freigaben je betreuter Person
// -----------------------------------------------------------------------------
export async function freigabenSpeichern(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = freigabenSchema.safeParse({
userId: formular.get('userId'),
betreuteId: formular.get('betreuteId'),
rolle: formular.get('rolle'),
freigegebeneModule: formular.getAll('freigegebeneModule').map(String),
})
if (!gelesen.success) {
return { fehler: 'Bitte prüfe deine Eingaben.', felder: felderAusZod(gelesen.error.issues) }
}
const { userId, betreuteId, rolle } = gelesen.data
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Freigaben vergibt nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: freigaben },
)
await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: freigaben },
})
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
// weiterbenutzen koennen.
if (aenderung.entfernt.length > 0 || aenderung.rolleGeaendert) {
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
}
const beschreibung = [
aenderung.rolleGeaendert
? `Rolle ${ROLLEN_BESCHRIFTUNG[aenderung.rolleVorher]} → ${ROLLEN_BESCHRIFTUNG[aenderung.rolleNachher]}`
: null,
aenderung.hinzugefuegt.length > 0
? `freigegeben: ${aenderung.hinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
aenderung.entfernt.length > 0
? `entzogen: ${aenderung.entfernt.map((m) => MODUL_BESCHRIFTUNG[m]).join(', ')}`
: null,
]
.filter(Boolean)
.join('; ')
await protokollieren({
aktion: aenderung.rolleGeaendert ? 'ROLLE_GEAENDERT' : 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `${ziel.name} für ${betreute.vorname} ${betreute.nachname}: ${beschreibung || 'unverändert'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
},
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
const warnung =
aenderung.sensibelHinzugefuegt.length > 0
? `Achtung: ${aenderung.sensibelHinzugefuegt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} ` +
`enthalten besonders schützenswerte Daten. ${ziel.name} kann sie ab sofort lesen.`
: undefined
return { erfolg: beschreibung ? `Gespeichert: ${beschreibung}.` : 'Es gab nichts zu ändern.', warnung }
}
export async function zugangEntziehen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const userId = String(formular.get('userId') ?? '')
const betreuteId = String(formular.get('betreuteId') ?? '')
const { benutzer, zugang, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
if (!darfFreigabenVergeben(zugang)) {
return { fehler: 'Zugänge entzieht nur der Betreuer dieser Person oder ein Administrator.' }
}
await erfordereFrischeAnmeldung(benutzer, `/einstellungen/freigaben?betreute=${betreuteId}`)
if (userId === benutzer.id) {
return { fehler: 'Du kannst dir den eigenen Zugang nicht entziehen.' }
}
const ziel = await db.user.findFirst({ where: { id: userId }, select: { name: true } })
await db.betreuungsZugang.deleteMany({ where: { userId, betreuteId } })
await db.user.update({ where: { id: userId }, data: { sitzungsGeneration: { increment: 1 } } })
await protokollieren({
aktion: 'FREIGABE_GEAENDERT',
entitaet: 'BetreuungsZugang',
entitaetId: userId,
bezeichnung: `Zugang von ${ziel?.name ?? userId} zu ${betreute.vorname} ${betreute.nachname} entzogen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/freigaben')
revalidatePath('/', 'layout')
return { erfolg: `${ziel?.name ?? 'Der Benutzer'} hat keinen Zugriff mehr auf diese Person.` }
}
+293 -293
View File
@@ -1,293 +1,293 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, papierkorbGrenze } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { entfernen } from '@/lib/speicher'
import { papierkorbSchema } from '@/lib/validierung/freigaben'
/**
* Papierkorb.
*
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
*
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
* es ihn gab und wer ihn entfernt hat.
*/
export type Aktionszustand = {
fehler?: string
erfolg?: string
}
/**
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
*/
type Eintragsart = {
bezeichnung: string
mehrzahl: string
modul: Modul
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
}
export type PapierkorbEintrag = {
id: string
entitaet: string
bezeichnung: string
geloeschtAm: Date
}
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
entitaet: string,
bezeichnung: string,
mehrzahl: string,
modul: Modul,
zugriff: {
findMany: (args: unknown) => Promise<T[]>
update: (args: unknown) => Promise<unknown>
delete: (args: unknown) => Promise<unknown>
},
titelFeld: string,
): Eintragsart {
return {
bezeichnung,
mehrzahl,
modul,
laden: async (betreuteId, seit) => {
const treffer = await zugriff.findMany({
where: { betreuteId, geloeschtAm: { gte: seit } },
orderBy: { geloeschtAm: 'desc' },
})
return treffer.map((datensatz) => ({
id: datensatz.id,
entitaet,
bezeichnung:
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
geloeschtAm: datensatz.geloeschtAm!,
}))
},
wiederherstellen: async (id, betreuteId) => {
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
},
endgueltigLoeschen: async (id, betreuteId) => {
await zugriff.delete({ where: { id, betreuteId } })
},
}
}
function eintragsarten(): Record<string, Eintragsart> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const z = db as any
return {
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
Vermoegensposten: einfach(
'Vermoegensposten',
'Vermögensposten',
'Vermögensposten',
'FINANZEN',
z.vermoegensposten,
'bezeichnung',
),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach(
'Schreibvorlage',
'Vorlage',
'Vorlagen',
'DOKUMENTE',
z.schreibvorlage,
'name',
),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach(
'Kontaktprotokoll',
'Kontaktprotokoll',
'Kontaktprotokolle',
'KONTAKTE',
z.kontaktprotokoll,
'gespraechspartner',
),
Gesundheitsereignis: einfach(
'Gesundheitsereignis',
'Gesundheitsereignis',
'Gesundheitsereignisse',
'GESUNDHEIT',
z.gesundheitsereignis,
'titel',
),
Genehmigung: einfach(
'Genehmigung',
'Genehmigung',
'Genehmigungen',
'VORGAENGE',
z.genehmigung,
'titel',
),
SozialhilfeAntrag: einfach(
'SozialhilfeAntrag',
'Sozialhilfeantrag',
'Sozialhilfeanträge',
'SOZIALHILFE',
z.sozialhilfeAntrag,
'leistungsart',
),
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
export async function papierkorbLaden(betreuteId: string): Promise<
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
> {
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
const seit = papierkorbGrenze()
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
continue
}
const eintraege = await beschreibung.laden(betreuteId, seit)
if (eintraege.length > 0) {
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
}
}
return gruppen
}
/**
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
*
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/
async function objektSchluesselFinden(
entitaet: string,
entitaetId: string,
betreuteId: string,
): Promise<string | null> {
if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return dokument?.objektSchluessel ?? null
}
if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return bericht?.objektSchluessel ?? null
}
return null
}
export async function wiederherstellen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'WIEDERHERGESTELLT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist wieder da.` }
}
/**
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
*/
export async function endgueltigLoeschen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten.
const schluessel = await objektSchluesselFinden(
gelesen.data.entitaet,
gelesen.data.entitaetId,
gelesen.data.betreuteId,
)
if (schluessel) {
try {
await entfernen(schluessel)
} catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
}
}
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'ENDGUELTIG_GELOESCHT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
}
'use server'
import { revalidatePath } from 'next/cache'
import type { Modul } from '@prisma/client'
import { db, papierkorbGrenze } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { entfernen } from '@/lib/speicher'
import { papierkorbSchema } from '@/lib/validierung/freigaben'
/**
* Papierkorb.
*
* Loeschen heisst in dieser Anwendung: "geloeschtAm" setzen. Der Datensatz
* bleibt 30 Tage sichtbar und wiederherstellbar. Das ist kein Luxus - in
* einer Betreuungsakte ist ein versehentlich geloeschter Bescheid ein
* echtes Problem, und der Nachweis gegenueber dem Gericht haengt daran.
*
* Endgueltig geloescht wird nur hier, nur einzeln und nur nach frischer
* Anmeldung. Danach ist der Datensatz weg; im Audit-Log bleibt stehen, dass
* es ihn gab und wer ihn entfernt hat.
*/
export type Aktionszustand = {
fehler?: string
erfolg?: string
}
/**
* Was im Papierkorb auftauchen kann. Bewusst eine feste Liste und keine
* dynamische Modellsuche: Der Entitaetsname kommt aus einem Formular, und
* daraus darf niemals ein beliebiger Datenbankzugriff werden.
*/
type Eintragsart = {
bezeichnung: string
mehrzahl: string
modul: Modul
laden: (betreuteId: string, seit: Date) => Promise<PapierkorbEintrag[]>
wiederherstellen: (id: string, betreuteId: string) => Promise<void>
endgueltigLoeschen: (id: string, betreuteId: string) => Promise<void>
}
export type PapierkorbEintrag = {
id: string
entitaet: string
bezeichnung: string
geloeschtAm: Date
}
/** Kleine Hilfe, damit die Tabelle unten nicht 20-mal dasselbe wiederholt. */
function einfach<T extends { id: string; geloeschtAm: Date | null }>(
entitaet: string,
bezeichnung: string,
mehrzahl: string,
modul: Modul,
zugriff: {
findMany: (args: unknown) => Promise<T[]>
update: (args: unknown) => Promise<unknown>
delete: (args: unknown) => Promise<unknown>
},
titelFeld: string,
): Eintragsart {
return {
bezeichnung,
mehrzahl,
modul,
laden: async (betreuteId, seit) => {
const treffer = await zugriff.findMany({
where: { betreuteId, geloeschtAm: { gte: seit } },
orderBy: { geloeschtAm: 'desc' },
})
return treffer.map((datensatz) => ({
id: datensatz.id,
entitaet,
bezeichnung:
String((datensatz as unknown as Record<string, unknown>)[titelFeld] ?? '(ohne Titel)'),
geloeschtAm: datensatz.geloeschtAm!,
}))
},
wiederherstellen: async (id, betreuteId) => {
await zugriff.update({ where: { id, betreuteId }, data: { geloeschtAm: null } })
},
endgueltigLoeschen: async (id, betreuteId) => {
await zugriff.delete({ where: { id, betreuteId } })
},
}
}
function eintragsarten(): Record<string, Eintragsart> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const z = db as any
return {
Vorgang: einfach('Vorgang', 'Vorgang', 'Vorgänge', 'VORGAENGE', z.vorgang, 'titel'),
Frist: einfach('Frist', 'Frist', 'Fristen', 'FRISTEN', z.frist, 'titel'),
Dokument: einfach('Dokument', 'Dokument', 'Dokumente', 'DOKUMENTE', z.dokument, 'titel'),
Konto: einfach('Konto', 'Konto', 'Konten', 'FINANZEN', z.konto, 'bezeichnung'),
Buchung: einfach('Buchung', 'Buchung', 'Buchungen', 'FINANZEN', z.buchung, 'gegenstelle'),
Vermoegensposten: einfach(
'Vermoegensposten',
'Vermögensposten',
'Vermögensposten',
'FINANZEN',
z.vermoegensposten,
'bezeichnung',
),
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
Schreibvorlage: einfach(
'Schreibvorlage',
'Vorlage',
'Vorlagen',
'DOKUMENTE',
z.schreibvorlage,
'name',
),
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
Kontaktprotokoll: einfach(
'Kontaktprotokoll',
'Kontaktprotokoll',
'Kontaktprotokolle',
'KONTAKTE',
z.kontaktprotokoll,
'gespraechspartner',
),
Gesundheitsereignis: einfach(
'Gesundheitsereignis',
'Gesundheitsereignis',
'Gesundheitsereignisse',
'GESUNDHEIT',
z.gesundheitsereignis,
'titel',
),
Genehmigung: einfach(
'Genehmigung',
'Genehmigung',
'Genehmigungen',
'VORGAENGE',
z.genehmigung,
'titel',
),
SozialhilfeAntrag: einfach(
'SozialhilfeAntrag',
'Sozialhilfeantrag',
'Sozialhilfeanträge',
'SOZIALHILFE',
z.sozialhilfeAntrag,
'leistungsart',
),
Leistung: einfach('Leistung', 'Leistung', 'Leistungen', 'SOZIALHILFE', z.leistung, 'bezeichnung'),
Bericht: einfach('Bericht', 'Bericht', 'Berichte', 'BERICHTE', z.bericht, 'titel'),
}
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Alles, was in den letzten 30 Tagen geloescht wurde. */
export async function papierkorbLaden(betreuteId: string): Promise<
{ art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[]
> {
const { zugang } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'lesen')
const seit = papierkorbGrenze()
const gruppen: { art: string; bezeichnung: string; eintraege: PapierkorbEintrag[] }[] = []
for (const [art, beschreibung] of Object.entries(eintragsarten())) {
// Ein Mitleser sieht auch im Papierkorb nur, was ihm freigegeben ist.
if (zugang.rolle === 'MITLESER' && !zugang.freigegebeneModule.includes(beschreibung.modul)) {
continue
}
const eintraege = await beschreibung.laden(betreuteId, seit)
if (eintraege.length > 0) {
gruppen.push({ art, bezeichnung: beschreibung.mehrzahl, eintraege })
}
}
return gruppen
}
/**
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
*
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/
async function objektSchluesselFinden(
entitaet: string,
entitaetId: string,
betreuteId: string,
): Promise<string | null> {
if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return dokument?.objektSchluessel ?? null
}
if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return bericht?.objektSchluessel ?? null
}
return null
}
export async function wiederherstellen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts wiederherstellen.' }
await art.wiederherstellen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'WIEDERHERGESTELLT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} aus dem Papierkorb geholt`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist wieder da.` }
}
/**
* Endgueltig loeschen. Danach ist der Datensatz weg - auch die Datei im
* Objektspeicher, falls es eine gibt. Nur nach frischer Anmeldung.
*/
export async function endgueltigLoeschen(
_zustand: Aktionszustand,
formular: FormData,
): Promise<Aktionszustand> {
const gelesen = papierkorbSchema.safeParse({
betreuteId: formular.get('betreuteId'),
entitaet: formular.get('entitaet'),
entitaetId: formular.get('entitaetId'),
})
if (!gelesen.success) return { fehler: 'Diese Angaben ergeben keinen Sinn.' }
const art = eintragsarten()[gelesen.data.entitaet]
if (!art) return { fehler: 'Diese Art von Datensatz kennt der Papierkorb nicht.' }
const { benutzer, zugang } = await erfordereZugang(gelesen.data.betreuteId, art.modul, 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du nichts löschen.' }
await erfordereFrischeAnmeldung(benutzer, '/einstellungen/papierkorb')
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten.
const schluessel = await objektSchluesselFinden(
gelesen.data.entitaet,
gelesen.data.entitaetId,
gelesen.data.betreuteId,
)
if (schluessel) {
try {
await entfernen(schluessel)
} catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
}
}
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
await protokollieren({
aktion: 'ENDGUELTIG_GELOESCHT',
entitaet: gelesen.data.entitaet,
entitaetId: gelesen.data.entitaetId,
bezeichnung: `${art.bezeichnung} endgültig gelöscht`,
betreuteId: gelesen.data.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/einstellungen/papierkorb')
return { erfolg: `${art.bezeichnung} ist endgültig entfernt.` }
}