docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

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

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

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

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

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

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

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react'
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <ShieldCheck aria-hidden="true" />}
{pending ? 'Wird geprüft …' : 'Bestätigen'}
</Button>
)
}
export function BestaetigenFormular({ weiter }: { weiter: string }) {
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from 'next'
import { ShieldAlert } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { BestaetigenFormular } from './formular'
export const metadata: Metadata = { title: 'Bestätigen' }
export const dynamic = 'force-dynamic'
export default async function BestaetigenSeite({
searchParams,
}: {
searchParams: Promise<{ weiter?: string }>
}) {
await erfordereBenutzer()
const { weiter = '/heute' } = await searchParams
return (
<div className="mx-auto max-w-md space-y-5">
<Card>
<CardHeader>
<CardTitle>Kurz bestätigen, bitte</CardTitle>
<CardDescription>
Für diesen Schritt brauchst du deine Zugangsdaten noch einmal.
</CardDescription>
</CardHeader>
<CardContent>
<BestaetigenFormular weiter={weiter} />
</CardContent>
</Card>
<Alert variant="hinweis">
<ShieldAlert aria-hidden="true" />
<AlertDescription>
Exportieren, endgültiges Löschen und die Benutzerverwaltung verlangen eine frische
Anmeldung. Der wahrscheinlichste Angriffsweg ist nicht der Einbruch in den Server,
sondern ein unbeaufsichtigter Rechner mit offener Sitzung.
</AlertDescription>
</Alert>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { redirect } from 'next/navigation'
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { UploadFormular } from '@/components/dokumente/upload-formular'
import { dokumentenkontext, vorgaengeDerAkte } from '@/lib/dokumente/kontext'
import { ZugriffVerweigert } from '@/lib/auth/sitzung'
import { env } from '@/lib/env'
import { formatBytes } from '@/lib/speicher/dateityp'
export const metadata = { title: 'Dokument aufnehmen' }
export const dynamic = 'force-dynamic'
/**
* Post aufnehmen.
*
* Der wichtigste Weg durch diese Anwendung: am Heimbett stehen, einen
* Bescheid mit dem Handy fotografieren, ihn dem Vorgang zuordnen und die
* Widerspruchsfrist daraus erzeugen. Alles auf einer Seite, ohne
* Zwischenschritte.
*/
export default async function NeuesDokumentSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
let kontext
try {
kontext = await dokumentenkontext('schreiben')
} catch (fehler) {
if (fehler instanceof ZugriffVerweigert) redirect('/dokumente')
throw fehler
}
const vorgaenge = await vorgaengeDerAkte(kontext.betreuteId)
const einzeln = (name: string) => {
const wert = parameter[name]
return Array.isArray(wert) ? wert[0] : wert
}
return (
<div className="space-y-6">
<SeitenKopf
titel="Dokument aufnehmen"
beschreibung="Foto oder PDF – der Text wird danach automatisch gelesen und du bekommst Absender, Datum, Aktenzeichen und mögliche Fristen als Vorschlag."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Aufnehmen' }]}
/>
<Alert variant="hinweis">
<ShieldCheck aria-hidden="true" />
<AlertDescription>
Erlaubt sind PDF, JPEG, PNG, WEBP, TIFF und HEIC, bis{' '}
{formatBytes(env.uploadMaxBytes)} je Datei. Geprüft wird der Inhalt, nicht die Endung –
eine Datei, die sich nur als PDF ausgibt, wird abgelehnt. Die Dateien verlassen diesen
Server nicht.
</AlertDescription>
</Alert>
<UploadFormular
vorgaenge={vorgaenge}
vorgangVorgewaehlt={einzeln('vorgang') ?? null}
kameraZuerst={einzeln('quelle') === 'kamera'}
/>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import Link from 'next/link'
import { BookOpen, FileText, Inbox, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { DokumentListe } from '@/components/dokumente/dokument-liste'
import { FilterLeiste } from '@/components/dokumente/filter-leiste'
import { BelegmappeFormular } from '@/components/dokumente/belegmappe-formular'
import { dokumentenkontext, tagsDerAkte, vorgaengeDerAkte } from '@/lib/dokumente/kontext'
import { dokumenteListe } from '@/lib/dokumente/suche'
import { filterAusParametern } from '@/lib/validierung/dokumente'
import { db, nichtGeloescht } from '@/lib/db'
export const metadata = { title: 'Dokumente' }
export const dynamic = 'force-dynamic'
/**
* Die Dokumentenliste mit Volltextsuche und Filtern.
*
* Die Suche geht ueber Titel, Absender, Aktenzeichen, Dateiname UND den
* per OCR erkannten Text - man findet einen Bescheid also auch dann, wenn
* man sich nur noch an eine Formulierung darin erinnert.
*/
export default async function DokumenteSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen')
const filter = filterAusParametern(parameter)
const proSeite = 25
const [{ eintraege, gesamt }, tags, vorgaenge, neuImPosteingang] = await Promise.all([
dokumenteListe(betreuteId, { ...filter, proSeite }),
tagsDerAkte(betreuteId),
vorgaengeDerAkte(betreuteId),
db.dokument.count({ where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht } }),
])
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
const gefiltert = Boolean(
filter.suche || filter.art || filter.richtung || filter.posteingangStatus || filter.von || filter.bis || filter.tagId,
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Dokumente"
beschreibung="Die Akte in digitaler Form. Suche findet auch Text aus abfotografierten Schreiben."
pfad={[{ titel: 'Dokumente' }]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/dokumente/posteingang">
<Inbox aria-hidden="true" />
Posteingang
{neuImPosteingang > 0 && (
<span className="ml-1 rounded-full bg-ampel-orange/15 px-2 py-0.5 text-xs font-semibold text-ampel-orange">
{neuImPosteingang}
</span>
)}
</Link>
</Button>
{darfSchreiben('DOKUMENTE') && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Dokument aufnehmen
</Link>
</Button>
)}
</>
}
/>
<FilterLeiste
werte={{
suche: filter.suche,
art: filter.art,
richtung: filter.richtung,
status: filter.posteingangStatus,
von: einzelnerParameter(parameter.von),
bis: einzelnerParameter(parameter.bis),
tag: filter.tagId,
}}
tags={tags}
/>
{eintraege.length === 0 ? (
<Leerzustand
icon={FileText}
titel={gefiltert ? 'Kein Dokument passt zu diesen Filtern' : 'Noch keine Dokumente'}
beschreibung={
gefiltert
? 'Setze die Filter zurück oder suche mit weniger Wörtern. Die Volltextsuche findet nur Dokumente, deren Text schon erkannt wurde.'
: 'Fotografiere den nächsten Brief, der im Kasten liegt – der Rest geht fast von selbst.'
}
aktion={
darfSchreiben('DOKUMENTE') && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Erstes Dokument aufnehmen
</Link>
</Button>
)
}
/>
) : (
<>
<p className="text-sm text-muted-foreground">
{gesamt} {gesamt === 1 ? 'Dokument' : 'Dokumente'}
{gefiltert ? ' passen zu den Filtern' : ' in der Akte'}
{seiten > 1 ? ` · Seite ${filter.seite} von ${seiten}` : ''}
</p>
<DokumentListe eintraege={eintraege} />
{seiten > 1 && (
<Blaettern aktuelleSeite={filter.seite} seiten={seiten} parameter={parameter} />
)}
</>
)}
<section className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex items-start gap-2">
<BookOpen className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<h2 className="font-semibold">Belegmappe für Gericht und Bezirk</h2>
<p className="text-sm text-muted-foreground">
Alle Belege eines Zeitraums oder eines Vorgangs als eine chronologisch sortierte PDF –
mit Deckblatt, Inhaltsverzeichnis und durchgehender Seitenzahl.
</p>
</div>
</div>
<BelegmappeFormular vorgaenge={vorgaenge} />
</section>
</div>
)
}
function einzelnerParameter(wert: string | string[] | undefined): string | null {
const roh = Array.isArray(wert) ? wert[0] : wert
return roh && roh.trim() !== '' ? roh : null
}
function Blaettern({
aktuelleSeite,
seiten,
parameter,
}: {
aktuelleSeite: number
seiten: number
parameter: Record<string, string | string[] | undefined>
}) {
const adresse = (seite: number) => {
const suche = new URLSearchParams()
for (const [name, wert] of Object.entries(parameter)) {
const roh = Array.isArray(wert) ? wert[0] : wert
if (roh && name !== 'seite') suche.set(name, roh)
}
suche.set('seite', String(seite))
return `/dokumente?${suche.toString()}`
}
return (
<nav className="flex items-center justify-between gap-3" aria-label="Blättern">
<Button variant="outline" disabled={aktuelleSeite <= 1} asChild={aktuelleSeite > 1}>
{aktuelleSeite > 1 ? <Link href={adresse(aktuelleSeite - 1)}>Zurück</Link> : <span>Zurück</span>}
</Button>
<span className="text-sm text-muted-foreground">
Seite {aktuelleSeite} von {seiten}
</span>
<Button variant="outline" disabled={aktuelleSeite >= seiten} asChild={aktuelleSeite < seiten}>
{aktuelleSeite < seiten ? (
<Link href={adresse(aktuelleSeite + 1)}>Weiter</Link>
) : (
<span>Weiter</span>
)}
</Button>
</nav>
)
}
@@ -0,0 +1,143 @@
import Link from 'next/link'
import { Inbox, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { ArtAbzeichen, OcrAbzeichen, RichtungAbzeichen } from '@/components/dokumente/abzeichen'
import { PosteingangStufen } from '@/components/dokumente/posteingang-stufen'
import { dokumentenkontext } from '@/lib/dokumente/kontext'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_ERKLAERUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
export const metadata = { title: 'Posteingang' }
export const dynamic = 'force-dynamic'
/**
* Das Posteingangsbuch.
*
* Vier Spalten, vier Stufen: NEU -> GESICHTET -> ZUGEORDNET ->
* BEANTWORTET. Das ist der digitale Nachbau des Poststapels auf dem
* Kuechentisch - mit dem Unterschied, dass hier nichts unter den Stapel
* rutschen kann.
*/
export default async function PosteingangSeite() {
const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen')
const schreibrecht = darfSchreiben('DOKUMENTE')
const dokumente = await db.dokument.findMany({
where: { betreuteId, ...nichtGeloescht },
select: {
id: true,
titel: true,
art: true,
richtung: true,
absender: true,
eingangAm: true,
schreibenVom: true,
posteingangStatus: true,
ocrStatus: true,
_count: { select: { verknuepfungen: true } },
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: 400,
})
const nachStufe = Object.fromEntries(
POSTEINGANG_STUFEN.map((stufe) => [stufe, dokumente.filter((d) => d.posteingangStatus === stufe)]),
) as Record<(typeof POSTEINGANG_STUFEN)[number], typeof dokumente>
return (
<div className="space-y-6">
<SeitenKopf
titel="Posteingang"
beschreibung="Was ist angekommen, was habe ich gesehen, was ist zugeordnet, was beantwortet? Nichts soll durchrutschen."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Posteingang' }]}
aktionen={
schreibrecht && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Post aufnehmen
</Link>
</Button>
)
}
/>
{dokumente.length === 0 ? (
<Leerzustand
icon={Inbox}
titel="Der Posteingang ist leer"
beschreibung="Sobald du einen Brief fotografierst oder ein PDF hochlädst, taucht er hier auf."
aktion={
schreibrecht && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Post aufnehmen
</Link>
</Button>
)
}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
{POSTEINGANG_STUFEN.map((stufe) => (
<section key={stufe} className="flex flex-col rounded-lg border bg-card">
<header className="border-b px-4 py-3">
<h2 className="flex items-center justify-between gap-2 font-semibold">
{POSTEINGANG_BESCHRIFTUNG[stufe]}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground">
{nachStufe[stufe].length}
</span>
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{POSTEINGANG_ERKLAERUNG[stufe]}
</p>
</header>
{nachStufe[stufe].length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Nichts auf dieser Stufe.
</p>
) : (
<ul className="divide-y">
{nachStufe[stufe].map((dokument) => (
<li key={dokument.id} className="space-y-2 px-4 py-3">
<Link
href={`/dokumente/${dokument.id}`}
className="block font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{dokument.titel}
</Link>
<p className="text-sm text-muted-foreground">
{dokument.absender ?? 'Ohne Absender'} ·{' '}
{formatDatum(dokument.eingangAm ?? dokument.schreibenVom)}
</p>
<div className="flex flex-wrap gap-1.5">
<ArtAbzeichen art={dokument.art} />
<RichtungAbzeichen richtung={dokument.richtung} />
<OcrAbzeichen status={dokument.ocrStatus} />
</div>
{dokument._count.verknuepfungen === 0 && stufe !== 'NEU' && (
<p className="text-xs text-ampel-orange">
Noch keinem Vorgang zugeordnet.
</p>
)}
<PosteingangStufen
dokumentId={dokument.id}
status={dokument.posteingangStatus}
schreibrecht={schreibrecht}
kompakt
/>
</li>
))}
</ul>
)}
</section>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,103 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Buchungsformular } from '@/components/finanzen/buchungs-formular'
import { db, nichtGeloescht } from '@/lib/db'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, tagAlsText } from '@/lib/finanzen/datum'
export const metadata = { title: 'Buchung erfassen' }
/**
* Buchung erfassen.
*
* Mit ?schnell=1 bleibt das Formular nach dem Speichern offen – für den
* Abend, an dem ein ganzer Kontoauszug nachgetragen wird.
*/
export default async function BuchungNeuSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const { betreute, darfSchreiben } = await finanzKontext()
if (!darfSchreiben('FINANZEN')) notFound()
const schnell = parameter.schnell === '1'
const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null
const [konten, belege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
}),
db.dokument.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
])
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel={schnell ? 'Schnellerfassung' : 'Buchung erfassen'}
beschreibung={
schnell
? 'Das Formular bleibt nach dem Speichern offen. Datum und Konto musst du je Buchung neu wählen – dafür wanderst du zügig durch den Kontoauszug.'
: 'Beträge immer positiv eintragen; ob Geld kommt oder geht, sagt die Richtung.'
}
pfad={[
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
{ titel: 'Kassenbuch', href: '/finanzen/buchungen' },
{ titel: schnell ? 'Schnellerfassung' : 'Neu' },
]}
aktionen={
<Button asChild variant="ghost">
<Link href={schnell ? '/finanzen/buchungen/neu' : '/finanzen/buchungen/neu?schnell=1'}>
{schnell ? 'Zur normalen Erfassung' : 'Zur Schnellerfassung'}
</Link>
</Button>
}
/>
{konten.length === 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Es gibt noch kein Konto</AlertTitle>
<AlertDescription>
Du kannst eine Buchung auch ohne Konto erfassen, aber dann lässt sie sich später
keinem Kontostand zuordnen – und die Rechenprobe der Rechnungslegung geht nicht auf.{' '}
<Link href="/finanzen/konten/neu" className="underline">
Erst ein Konto anlegen
</Link>
.
</AlertDescription>
</Alert>
)}
<Buchungsformular
betreuteId={betreute.id}
konten={
kontoId
? [
...konten.filter((k) => k.id === kontoId),
...konten.filter((k) => k.id !== kontoId),
]
: konten
}
belege={belege}
schnellerfassung={schnell}
standardDatum={tagAlsText(heute())}
/>
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
</div>
)
}
+286
View File
@@ -0,0 +1,286 @@
import Link from 'next/link'
import { Banknote, Plus, Upload } from 'lucide-react'
import type { Prisma } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine'
import { Buchungsfilter } from '@/components/finanzen/buchungs-filter'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { buchungsfilterSchema } from '@/lib/validierung/finanzen'
export const metadata = { title: 'Kassenbuch' }
const HOECHSTZAHL = 300
/**
* Das Kassenbuch.
*
* Zwei Dinge sind hier absichtlich auffällig: Buchungen ohne Beleg und
* ohne Begründung werden rot markiert, und eine Buchung, die auf einem
* Konto des Betreuers läuft und trotzdem als Vermögen der Betreuten
* gilt, bekommt eine eigene Warnung. Beides sind die Punkte, an denen
* eine Rechnungslegung beim Gericht auseinanderfällt.
*/
export default async function BuchungenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const roh = await searchParams
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const flach: Record<string, string> = {}
for (const [schluessel, wert] of Object.entries(roh)) {
if (typeof wert === 'string') flach[schluessel] = wert
}
const gepruft = buchungsfilterSchema.safeParse(flach)
const filter = gepruft.success
? gepruft.data
: {
kontoId: null,
kategorie: null,
richtung: null,
von: null,
bis: null,
suche: null,
nurOhneBeleg: false,
nurEigeneAuslagen: false,
}
const bedingung: Prisma.BuchungWhereInput = {
betreuteId: betreute.id,
...nichtGeloescht,
...(filter.kontoId ? { kontoId: filter.kontoId } : {}),
...(filter.kategorie ? { kategorie: filter.kategorie } : {}),
...(filter.richtung ? { richtung: filter.richtung } : {}),
...(filter.von || filter.bis
? {
datum: {
...(filter.von ? { gte: filter.von } : {}),
...(filter.bis ? { lte: filter.bis } : {}),
},
}
: {}),
// Der Verwendungszweck ist verschluesselt und laesst sich nicht
// durchsuchen - gesucht wird deshalb nur in der Gegenstelle.
...(filter.suche ? { gegenstelle: { contains: filter.suche, mode: 'insensitive' } } : {}),
...(filter.nurEigeneAuslagen ? { betrifftBetreute: false } : {}),
...(filter.nurOhneBeleg
? { belegId: null, OR: [{ belegFehltBegruendung: null }, { belegFehltBegruendung: '' }] }
: {}),
}
const [zeilen, gesamt, konten] = await Promise.all([
db.buchung.findMany({
where: bedingung,
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }],
take: HOECHSTZAHL,
}),
db.buchung.count({ where: bedingung }),
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { bezeichnung: 'asc' },
}),
])
const buchungen: BuchungEingabe[] = zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
const zahlen = kennzahlen(buchungen)
return (
<div className="space-y-6">
<SeitenKopf
titel="Kassenbuch"
beschreibung="Jede Einnahme und jede Ausgabe. Aus diesen Zeilen entsteht die Rechnungslegung – deshalb gehört zu jeder Zeile ein Beleg oder eine Begründung, warum keiner da ist."
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Kassenbuch' }]}
aktionen={
schreiben ? (
<>
<Button asChild variant="secondary">
<Link href="/finanzen/buchungen/import">
<Upload aria-hidden="true" />
Importieren
</Link>
</Button>
<Button asChild>
<Link href="/finanzen/buchungen/neu">
<Plus aria-hidden="true" />
Buchung erfassen
</Link>
</Button>
</>
) : undefined
}
/>
<Buchungsfilter
konten={konten}
aktiv={{
kontoId: filter.kontoId ?? '',
kategorie: filter.kategorie ?? '',
richtung: filter.richtung ?? '',
von: filter.von ? filter.von.toISOString().slice(0, 10) : '',
bis: filter.bis ? filter.bis.toISOString().slice(0, 10) : '',
suche: filter.suche ?? '',
nurOhneBeleg: filter.nurOhneBeleg,
nurEigeneAuslagen: filter.nurEigeneAuslagen,
}}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl titel="Buchungen in der Auswahl" wert={gesamt} />
<Kennzahl
titel="Einnahmen"
wert={formatCent(centZuZahl(zahlen.einnahmenCent))}
hinweis="ohne Umbuchungen und eigene Auslagen"
/>
<Kennzahl
titel="Ausgaben"
wert={formatCent(centZuZahl(zahlen.ausgabenCent))}
hinweis="ohne Umbuchungen und eigene Auslagen"
/>
<Kennzahl
titel="Ohne Beleg und ohne Begründung"
wert={zahlen.beleg.ohneNachweis}
hinweis={
zahlen.beleg.ohneNachweis > 0
? 'Genau danach fragt der Rechtspfleger.'
: 'Da fehlt nichts.'
}
ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
/>
</div>
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
{buchungen.length === 0 ? (
<Leerzustand
icon={Banknote}
titel={gesamt === 0 ? 'Noch keine Buchung erfasst' : 'Keine Buchung passt zu diesem Filter'}
beschreibung={
gesamt === 0
? 'Erfasse die erste Buchung von Hand oder lies einen Kontoauszug als CSV ein.'
: 'Setz den Filter zurück oder wähl einen anderen Zeitraum.'
}
aktion={
schreiben && gesamt === 0 ? (
<Button asChild>
<Link href="/finanzen/buchungen/neu">
<Plus aria-hidden="true" />
Buchung erfassen
</Link>
</Button>
) : undefined
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Vorgang</TableHead>
<TableHead>Konto</TableHead>
<TableHead>Beleg</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buchungen.map((buchung) => (
<TableRow
key={buchung.id}
className={belegFehlt(buchung) ? 'bg-ampel-rot/5' : undefined}
>
<TableCell className="whitespace-nowrap align-top">
{formatDatum(buchung.datum)}
</TableCell>
<TableCell className="align-top">
<Link
href={`/finanzen/buchungen/${buchung.id}`}
className="font-medium hover:underline"
>
{buchung.gegenstelle ?? KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
</Link>
<span className="block text-sm text-muted-foreground">
{RICHTUNG_BESCHRIFTUNG[buchung.richtung]} ·{' '}
{KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
</span>
{buchung.verwendungszweck && (
<span className="block text-sm text-muted-foreground">
{buchung.verwendungszweck}
</span>
)}
<span className="mt-1 flex flex-wrap gap-1.5">
{!buchung.betrifftBetreute && (
<Badge variant="secondary">eigene Auslage</Badge>
)}
{buchung.vermoegenswirksam && (
<Badge variant="outline">vermögenswirksam</Badge>
)}
{vermischungsWarnung(buchung) && <Vermischungswarnung kurz />}
</span>
</TableCell>
<TableCell className="align-top text-sm text-muted-foreground">
{buchung.kontoBezeichnung ?? 'kein Konto'}
</TableCell>
<TableCell className="align-top">
<Belegzeichen
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
/>
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{gesamt > buchungen.length && (
<p className="text-sm text-muted-foreground">
Es werden die {HOECHSTZAHL} jüngsten von {gesamt} Buchungen gezeigt. Grenze den
Zeitraum ein, um die älteren zu sehen.
</p>
)}
</>
)}
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
</div>
)
}
+245
View File
@@ -0,0 +1,245 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Betrag } from '@/components/finanzen/bausteine'
import { Kontoformular } from '@/components/finanzen/konto-formular'
import { Saldoformular } from '@/components/finanzen/saldo-formular'
import { Loeschknopf } from '@/components/finanzen/aktionen'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, tagAlsText } from '@/lib/finanzen/datum'
import { ibanFormatieren } from '@/lib/finanzen/iban'
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { kontoLoeschen, saldostandLoeschen } from '@/server/actions/finanzen'
export const metadata = { title: 'Konto' }
/**
* Ein einzelnes Konto: Stammdaten, Historie der Kontostände und die
* letzten Buchungen. Kontostände werden hier angelegt und nie
* überschrieben – jede alte Aufstellung bleibt reproduzierbar.
*/
export default async function KontoSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const konto = await db.konto.findFirst({
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
include: {
saldostaende: { orderBy: { stichtag: 'desc' } },
_count: { select: { buchungen: true } },
},
})
if (!konto) notFound()
const offen = oeffnen('Konto', konto)
const buchungen = await db.buchung.findMany({
where: { kontoId: konto.id, ...nichtGeloescht },
orderBy: { datum: 'desc' },
take: 10,
select: {
id: true,
datum: true,
betragCent: true,
richtung: true,
kategorie: true,
gegenstelle: true,
},
})
return (
<div className="space-y-8">
<SeitenKopf
titel={konto.bezeichnung}
beschreibung={
<span className="space-y-1">
<span className="block">
{KONTO_ART_BESCHRIFTUNG[konto.art]}
{konto.bank ? ` · ${konto.bank}` : ''}
</span>
{offen.iban && (
<span className="block font-mono text-sm">{ibanFormatieren(offen.iban)}</span>
)}
</span>
}
pfad={[
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
{ titel: 'Konten', href: '/finanzen/konten' },
{ titel: konto.bezeichnung },
]}
aktionen={
<Button asChild variant="ghost">
<Link href={`/finanzen/buchungen?kontoId=${konto.id}`}>
Buchungen dieses Kontos
<ArrowRight aria-hidden="true" />
</Link>
</Button>
}
/>
{konto.istBetreuerkonto && (
<Badge variant="orange">
Konto des Betreuers – gehört nicht zum Vermögen der betreuten Person
</Badge>
)}
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader>
<CardTitle>Kontostände</CardTitle>
<CardDescription>
Ein Stand gilt als Bestand am Ende seines Stichtags. Stände werden nie überschrieben –
so lässt sich eine abgegebene Vermögensübersicht Jahre später exakt nachbilden.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{konto.saldostaende.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Noch kein Kontostand erfasst. Ohne Stand bleibt dieses Konto in der Rechnungslegung
mit 0,00 € stehen – und die Rechenprobe geht nicht auf.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead>Stand</TableHead>
<TableHead>Quelle</TableHead>
{schreiben && <TableHead className="text-right">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{konto.saldostaende.map((stand) => (
<TableRow key={stand.id}>
<TableCell className="whitespace-nowrap">
{formatDatum(stand.stichtag)}
</TableCell>
<TableCell>
<Betrag cent={centZuZahl(stand.betragCent)} fett />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stand.quelle ?? 'keine Quelle angegeben'}
</TableCell>
{schreiben && (
<TableCell className="text-right">
<Loeschknopf
aktion={saldostandLoeschen}
felder={{ betreuteId: betreute.id, saldostandId: stand.id }}
frage={`Kontostand vom ${formatDatum(stand.stichtag)} endgültig entfernen? Er wird nicht in den Papierkorb gelegt.`}
/>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
{schreiben && (
<div className="border-t pt-6">
<h3 className="mb-3 text-base font-semibold">Neuen Kontostand festhalten</h3>
<Saldoformular
betreuteId={betreute.id}
konten={[{ id: konto.id, bezeichnung: konto.bezeichnung }]}
kontoId={konto.id}
standardStichtag={tagAlsText(heute())}
/>
</div>
)}
</CardContent>
</Card>
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader>
<CardTitle>Letzte Buchungen</CardTitle>
<CardDescription>
{konto._count.buchungen} Buchung(en) insgesamt auf diesem Konto.
</CardDescription>
</CardHeader>
<CardContent>
{buchungen.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Buchungen auf diesem Konto.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Gegenstelle</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{buchungen.map((buchung) => (
<TableRow key={buchung.id}>
<TableCell className="whitespace-nowrap">
{formatDatum(buchung.datum)}
</TableCell>
<TableCell>
<Link
href={`/finanzen/buchungen/${buchung.id}`}
className="hover:underline"
>
{buchung.gegenstelle ?? 'ohne Gegenstelle'}
</Link>
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ------------------------------------------------------------- */}
{schreiben && (
<Card>
<CardHeader>
<CardTitle>Stammdaten ändern</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<Kontoformular
betreuteId={betreute.id}
konto={{
id: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
art: konto.art,
iban: offen.iban,
bic: konto.bic,
istBetreuerkonto: konto.istBetreuerkonto,
eroeffnetAm: tagAlsText(konto.eroeffnetAm) || null,
geschlossenAm: tagAlsText(konto.geschlossenAm) || null,
}}
/>
<div className="border-t pt-6">
<Loeschknopf
aktion={kontoLoeschen}
felder={{ betreuteId: betreute.id, kontoId: konto.id }}
beschriftung="Konto in den Papierkorb"
variante="outline"
frage="Konto in den Papierkorb legen? Es bleibt 30 Tage wiederherstellbar. Konten mit Buchungen werden nicht gelöscht – trage stattdessen ein Schließungsdatum ein."
/>
</div>
</CardContent>
</Card>
)}
<Rechtshinweis rechtsgrundlage="§ 1835 BGB" />
</div>
)
}
@@ -0,0 +1,30 @@
import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Kontoformular } from '@/components/finanzen/konto-formular'
import { finanzKontext } from '@/lib/finanzen/seite'
export const metadata = { title: 'Konto anlegen' }
export default async function KontoNeuSeite() {
const { betreute, darfSchreiben } = await finanzKontext()
if (!darfSchreiben('FINANZEN')) notFound()
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel="Konto anlegen"
beschreibung="Die IBAN wird verschlüsselt gespeichert. Zusätzlich wird ein Blindindex abgelegt – damit erkennt die Anwendung ein doppelt angelegtes Konto, ohne die IBAN im Klartext vorzuhalten."
pfad={[
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
{ titel: 'Konten', href: '/finanzen/konten' },
{ titel: 'Neu' },
]}
/>
<Kontoformular betreuteId={betreute.id} />
<Rechtshinweis rechtsgrundlage="§ 1835 BGB" />
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import Link from 'next/link'
import { Landmark, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
import { Betrag } from '@/components/finanzen/bausteine'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute } from '@/lib/finanzen/datum'
import { kontenLaden, kontostaendeZumStichtag } from '@/lib/berichte/gemeinsam'
export const metadata = { title: 'Konten' }
export default async function KontenSeite() {
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const stichtag = heute()
const konten = await kontenLaden(betreute.id)
const staende = kontostaendeZumStichtag(konten, stichtag)
const summeCent = staende
.filter((s) => !s.istBetreuerkonto)
.reduce((s, z) => s + (z.betragCent ?? 0n), 0n)
return (
<div className="space-y-6">
<SeitenKopf
titel="Konten"
beschreibung="Alle Konten und Kassen, über die Geld der betreuten Person läuft – einschließlich Verwahrgeld im Heim und Barkasse."
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Konten' }]}
aktionen={
schreiben ? (
<Button asChild>
<Link href="/finanzen/konten/neu">
<Plus aria-hidden="true" />
Konto anlegen
</Link>
</Button>
) : undefined
}
/>
{konten.length === 0 ? (
<Leerzustand
icon={Landmark}
titel="Noch kein Konto erfasst"
beschreibung="Ohne Konten gibt es keine Bestände – und ohne Bestände keine Rechnungslegung. Fang mit dem Girokonto an."
aktion={
schreiben ? (
<Button asChild>
<Link href="/finanzen/konten/neu">
<Plus aria-hidden="true" />
Konto anlegen
</Link>
</Button>
) : undefined
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Art</TableHead>
<TableHead>Bank</TableHead>
<TableHead>Stand vom</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staende.map((stand) => (
<TableRow key={stand.kontoId}>
<TableCell>
<Link
href={`/finanzen/konten/${stand.kontoId}`}
className="font-medium hover:underline"
>
{stand.bezeichnung}
</Link>
{stand.ibanMaskiert && (
<span className="block text-sm text-muted-foreground">
{stand.ibanMaskiert}
</span>
)}
{stand.istBetreuerkonto && (
<Badge variant="orange" className="mt-1">
Konto des Betreuers
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stand.artBeschriftung}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stand.bank ?? '–'}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{stand.stichtag ? formatDatum(stand.stichtag) : 'nie erfasst'}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(stand.betragCent)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="flex items-baseline justify-between gap-4 border-t pt-3 font-semibold">
<span>Summe der Konten der betreuten Person</span>
<span className="tabular-nums">{formatCent(centZuZahl(summeCent))}</span>
</p>
<p className="text-sm text-muted-foreground">
Konten des Betreuers sind in dieser Summe nicht enthalten – sie gehören nicht zum
Vermögen der betreuten Person.
</p>
</>
)}
</div>
)
}
+318
View File
@@ -0,0 +1,318 @@
import Link from 'next/link'
import { ArrowRight, Banknote, FileDown, Landmark, Plus, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
Belegzeichen,
Betrag,
Hinweisliste,
Kennzahl,
Vermischungswarnung,
} from '@/components/finanzen/bausteine'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam'
export const metadata = { title: 'Vermögen & Kasse' }
/**
* Übersichtsseite des Finanzmoduls.
*
* Sie beantwortet die vier Fragen, die man sich beim Öffnen stellt:
* Was ist da? Wo liegt es? Was ist zuletzt passiert? Und: Wo fehlt noch
* ein Beleg?
*/
export default async function FinanzenSeite() {
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const stichtag = heute()
const [konten, buchungen, vermoegen] = await Promise.all([
kontenLaden(betreute.id),
buchungenLaden(betreute.id),
vermoegenLaden(betreute.id, stichtag),
])
const eigeneKonten = konten.filter((k) => !k.istBetreuerkonto)
const staende = kontostaendeZumStichtag(eigeneKonten, stichtag)
const summeKontenCent = staende.reduce((s, z) => s + (z.betragCent ?? 0n), 0n)
const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1)
const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate)
const zahlen = kennzahlen(letzteZwoelf)
const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8)
const jahr = betreute.bestellungAm ? laufendesBetreuungsjahr(betreute.bestellungAm) : null
const hinweise: string[] = []
for (const stand of staende) {
if (stand.betragCent === null) {
hinweise.push(`Für „${stand.bezeichnung}“ ist noch kein Kontostand erfasst.`)
} else if (stand.stichtag && stand.stichtag < monatePlus(stichtag, -3)) {
hinweise.push(
`Der Stand von „${stand.bezeichnung}“ ist vom ${formatDatum(stand.stichtag)} und damit ` +
'älter als drei Monate.',
)
}
}
if (!betreute.bestellungAm) {
hinweise.push(
'In den Stammdaten fehlt das Datum der Bestellung. Ohne dieses Datum lässt sich das ' +
'Betreuungsjahr nicht bestimmen – und damit auch keine Rechnungslegung erzeugen.',
)
}
return (
<div className="space-y-8">
<SeitenKopf
titel="Vermögen & Kasse"
beschreibung={
jahr
? `Aktuell läuft das ${jahr.bezeichnung}. Alles hier fließt in das Vermögensverzeichnis und die Rechnungslegung.`
: 'Konten, Kassenbuch und Vermögensposten. Von hier aus entstehen beide Berichte ans Betreuungsgericht.'
}
pfad={[{ titel: 'Vermögen & Kasse' }]}
aktionen={
schreiben ? (
<>
<Button asChild variant="secondary">
<Link href="/finanzen/buchungen/neu">
<Plus aria-hidden="true" />
Buchung erfassen
</Link>
</Button>
<Button asChild>
<Link href="/finanzen/berichte">
<FileDown aria-hidden="true" />
Berichte
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl
titel="Guthaben auf Konten"
wert={formatCent(centZuZahl(summeKontenCent))}
hinweis={`${staende.length} Konto/Konten der betreuten Person`}
href="/finanzen/konten"
/>
<Kennzahl
titel="Reinvermögen"
wert={formatCent(centZuZahl(summeKontenCent + vermoegen.summeAktivaCent - vermoegen.summePassivaCent))}
hinweis="Konten und Vermögensposten, abzüglich Verbindlichkeiten"
href="/finanzen/vermoegen"
/>
<Kennzahl
titel="Belegquote (12 Monate)"
wert={`${zahlen.beleg.quoteProzent} %`}
hinweis={
zahlen.beleg.ohneNachweis > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung`
: 'Alle Buchungen sind belegt oder begründet'
}
ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
href="/finanzen/buchungen?nurOhneBeleg=on"
/>
<Kennzahl
titel="Saldo der letzten 12 Monate"
wert={formatCent(centZuZahl(zahlen.saldoCent))}
hinweis={`${formatCent(centZuZahl(zahlen.einnahmenCent))} ein, ${formatCent(centZuZahl(zahlen.ausgabenCent))} aus`}
ton={zahlen.saldoCent < 0n ? 'achtung' : 'neutral'}
href="/finanzen/buchungen"
/>
</div>
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" />
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader>
<CardTitle>Konten</CardTitle>
<CardDescription>
Stand zum {formatDatum(stichtag)} – jeweils der jüngste erfasste Saldostand.
</CardDescription>
</CardHeader>
<CardContent>
{konten.length === 0 ? (
<Leerzustand
icon={Landmark}
titel="Noch kein Konto erfasst"
beschreibung="Fang mit dem Girokonto an, über das Rente und Heimkosten laufen. Das Verwahrgeldkonto im Heim gehört ebenfalls dazu."
aktion={
schreiben ? (
<Button asChild>
<Link href="/finanzen/konten/neu">
<Plus aria-hidden="true" />
Konto anlegen
</Link>
</Button>
) : undefined
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Art</TableHead>
<TableHead>Stand vom</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{kontostaendeZumStichtag(konten, stichtag).map((stand) => (
<TableRow key={stand.kontoId}>
<TableCell>
<Link
href={`/finanzen/konten/${stand.kontoId}`}
className="font-medium hover:underline"
>
{stand.bezeichnung}
</Link>
{stand.istBetreuerkonto && (
<span className="block text-sm text-muted-foreground">
Konto des Betreuers – zählt nicht zum Vermögen der Betreuten
</span>
)}
{stand.ibanMaskiert && (
<span className="block text-sm text-muted-foreground">
{stand.ibanMaskiert}
</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stand.artBeschriftung}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{stand.stichtag ? formatDatum(stand.stichtag) : 'nie erfasst'}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(stand.betragCent)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div>
<CardTitle>Zuletzt gebucht</CardTitle>
<CardDescription>Die acht jüngsten Einträge im Kassenbuch.</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{schreiben && (
<Button asChild variant="ghost" size="sm">
<Link href="/finanzen/buchungen/import">
<Upload aria-hidden="true" />
Importieren
</Link>
</Button>
)}
<Button asChild variant="ghost" size="sm">
<Link href="/finanzen/buchungen">
Alle ansehen
<ArrowRight aria-hidden="true" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{letzte.length === 0 ? (
<Leerzustand
icon={Banknote}
titel="Das Kassenbuch ist noch leer"
beschreibung="Erfasse die erste Buchung von Hand oder lies einen Kontoauszug als CSV ein."
aktion={
schreiben ? (
<div className="flex flex-wrap justify-center gap-2">
<Button asChild>
<Link href="/finanzen/buchungen/neu">
<Plus aria-hidden="true" />
Buchung erfassen
</Link>
</Button>
<Button asChild variant="secondary">
<Link href="/finanzen/buchungen/import">
<Upload aria-hidden="true" />
CSV einlesen
</Link>
</Button>
</div>
) : undefined
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Vorgang</TableHead>
<TableHead>Beleg</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{letzte.map((buchung) => (
<TableRow key={buchung.id}>
<TableCell className="whitespace-nowrap">
{formatDatum(buchung.datum)}
</TableCell>
<TableCell>
<Link
href={`/finanzen/buchungen/${buchung.id}`}
className="font-medium hover:underline"
>
{buchung.gegenstelle ?? KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
</Link>
<span className="block text-sm text-muted-foreground">
{KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
{!buchung.betrifftBetreute && ' · eigene Auslage'}
{buchung.vermoegenswirksam && ' · vermögenswirksam'}
</span>
{vermischungsWarnung(buchung) && (
<span className="mt-1 block">
<Vermischungswarnung kurz />
</span>
)}
</TableCell>
<TableCell>
<Belegzeichen
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
/>
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, §§ 1859, 1865 BGB" />
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from 'next'
import { AlertTriangle } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { HinweisSuche } from '@/components/hinweise/hinweis-suche'
import { hinweiseLaden } from '@/lib/hinweise/laden'
export const metadata: Metadata = { title: 'Hinweise' }
/**
* Alle Hinweiskarten auf einen Blick.
*
* Zweck der Seite: nachschlagen, wenn man weiss, dass es zu einem Thema
* eine Regel gab, aber nicht mehr welche. Im Alltag begegnen einem die
* Karten dort, wo sie gebraucht werden - hier stehen sie alle.
*/
export default function HinweiseSeite() {
const { karten, fehler } = hinweiseLaden()
return (
<div className="space-y-6">
<SeitenKopf
titel="Hinweise"
pfad={[{ titel: 'Hinweise' }]}
beschreibung={
<>
Kurze Merkzettel zu den Regeln, an denen in der rechtlichen Betreuung am häufigsten
etwas schiefgeht – Genehmigungen, Ausschlussfristen, geschütztes Vermögen. Jede Karte
nennt die Rechtsgrundlage und verlinkt den Gesetzestext.
</>
}
/>
{fehler.length > 0 && (
<Alert variant="warnung">
<AlertTriangle aria-hidden="true" />
<AlertTitle>Die Wissensbasis konnte nicht gelesen werden</AlertTitle>
<AlertDescription>
<p>
In <code>daten/hinweise.json</code> stimmt etwas nicht. Bis das behoben ist, werden
keine Hinweise angezeigt – der Rest der Anwendung funktioniert normal weiter.
</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{fehler.slice(0, 5).map((meldung) => (
<li key={meldung}>{meldung}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<HinweisSuche karten={karten} />
<Rechtshinweis />
</div>
)
}
@@ -0,0 +1,67 @@
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { ImmobilieFormular } from '@/components/immobilie/immobilie-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'
export default async function ImmobilieBearbeiten({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/immobilie/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
if (!darfSchreiben('IMMOBILIE')) redirect(`/immobilie/${id}`)
const immobilie = await db.immobilie.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
})
if (!immobilie) notFound()
const offen = oeffnen('Immobilie', immobilie)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${immobilie.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert — im Audit-Log steht später, was wann geändert wurde."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Immobilie', href: '/immobilie' },
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
{ titel: 'Bearbeiten' },
]}
/>
<ImmobilieFormular
betreuteId={aktive.id}
werte={{
id: immobilie.id,
bezeichnung: immobilie.bezeichnung,
strasse: immobilie.strasse ?? '',
plz: immobilie.plz ?? '',
ort: immobilie.ort ?? '',
grundbuchamt: immobilie.grundbuchamt ?? '',
grundbuchBlatt: immobilie.grundbuchBlatt ?? '',
gemarkung: immobilie.gemarkung ?? '',
flurstueck: immobilie.flurstueck ?? '',
eigentumsanteil: immobilie.eigentumsanteil ?? '',
wohnflaecheQm: immobilie.wohnflaecheQm?.toString() ?? '',
grundstueckQm: immobilie.grundstueckQm?.toString() ?? '',
baujahr: immobilie.baujahr?.toString() ?? '',
bewohntStatus: immobilie.bewohntStatus ?? '',
leerstandSeit: immobilie.leerstandSeit ? alsIsoTag(immobilie.leerstandSeit) : '',
notizen: offen.notizen ?? '',
}}
/>
</div>
)
}
@@ -0,0 +1,95 @@
import { notFound } from 'next/navigation'
import { ShieldAlert } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Checkliste } from '@/components/immobilie/checkliste'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function LeerstandsCheckliste({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/immobilie/${id}/checkliste`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
const schreiben = darfSchreiben('IMMOBILIE')
const immobilie = await db.immobilie.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
checklistenPunkte: { orderBy: [{ reihenfolge: 'asc' }, { titel: 'asc' }] },
},
})
if (!immobilie) notFound()
const heute = heuteAlsTag()
return (
<div className="space-y-6">
<SeitenKopf
titel="Pflichten bei Leerstand"
beschreibung={
immobilie.leerstandSeit
? `${immobilie.bezeichnung} steht seit ${formatDatum(immobilie.leerstandSeit)} leer.`
: `${immobilie.bezeichnung} — Pflichten, die auch ohne Bewohner weiterlaufen.`
}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Immobilie', href: '/immobilie' },
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
{ titel: 'Pflichten bei Leerstand' },
]}
/>
<Alert variant="warnung">
<ShieldAlert aria-hidden="true" />
<AlertTitle>Der teuerste Fehler zuerst</AlertTitle>
<AlertDescription>
<p>
Leerstand ist für den Gebäudeversicherer eine Gefahrerhöhung. Wird sie nicht
angezeigt, kann der Versicherer im Schadensfall leistungsfrei werden — ein
Wasserschaden im leeren Haus geht dann zulasten des Vermögens der betreuten
Person. Melde den Leerstand schriftlich, frag ausdrücklich nach den Auflagen
(Kontrollgänge, abgestellte Wasserzufuhr) und nimm die Antwort zur Akte.
</p>
</AlertDescription>
</Alert>
<Checkliste
betreuteId={aktive.id}
immobilieId={id}
darfSchreiben={schreiben}
punkte={immobilie.checklistenPunkte.map((punkt) => ({
id: punkt.id,
titel: punkt.titel,
beschreibung: punkt.beschreibung,
erledigt: punkt.erledigt,
erledigtAm: punkt.erledigtAm?.toISOString() ?? null,
faelligAm: punkt.faelligAm?.toISOString() ?? null,
wiederholungRrule: punkt.wiederholungRrule,
tageBisFaellig: punkt.faelligAm ? tageZwischen(heute, punkt.faelligAm) : null,
}))}
/>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB, § 823 BGB, § 23 VVG" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: Die Verkehrssicherungspflicht (§ 823 BGB) trifft den Eigentümer, auch
wenn niemand im Haus wohnt — für den Sturz auf dem ungeräumten Gehweg haftest du als
Vertreter mit dem Vermögen der betreuten Person. § 23 VVG verpflichtet dazu, eine
Gefahrerhöhung wie den Leerstand dem Versicherer anzuzeigen. Und § 1821 BGB verlangt,
das Vermögen der betreuten Person zu erhalten — ein Haus, das über einen Winter
durchfriert, ist das Gegenteil davon.
</p>
</div>
)
}
@@ -0,0 +1,90 @@
import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Inventar } from '@/components/immobilie/inventar'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { inventarNotizLesen, inventarSumme } from '@/lib/immobilie/inventar'
export const dynamic = 'force-dynamic'
export default async function InventarSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/immobilie/${id}/inventar`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
const schreiben = darfSchreiben('IMMOBILIE')
const immobilie = await db.immobilie.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
if (!immobilie) notFound()
const posten = await db.vermoegensposten.findMany({
where: {
betreuteId: aktive.id,
immobilieId: id,
art: 'WERTGEGENSTAND',
...nichtGeloescht,
},
include: {
wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 },
verknuepfungen: {
include: {
dokument: { select: { id: true, titel: true, art: true, schreibenVom: true } },
},
},
},
orderBy: { bezeichnung: 'asc' },
})
const stuecke = posten.map((p) => {
const offen = oeffnen('Vermoegensposten', p)
const notiz = inventarNotizLesen(offen.notizen)
return {
id: p.id,
bezeichnung: p.bezeichnung,
raum: notiz.raum,
verbleib: notiz.verbleib,
bemerkung: notiz.bemerkung,
wertCent: centZuZahl(p.wertstaende[0]?.wertCent ?? null),
fotos: p.verknuepfungen
.filter((v) => v.dokument.art === 'FOTO')
.map((v) => ({
id: v.dokument.id,
titel: v.dokument.titel,
aufgenommenAm: v.dokument.schreibenVom?.toISOString() ?? null,
})),
}
})
const summeCent = centZuZahl(
inventarSumme(posten.map((p) => ({ wertCent: p.wertstaende[0]?.wertCent ?? null }))),
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Fotodokumentation und Inventar"
beschreibung={`${immobilie.bezeichnung} — was im Haus steht, was es wert ist und wo es geblieben ist.`}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Immobilie', href: '/immobilie' },
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
{ titel: 'Inventar' },
]}
/>
<Inventar
betreuteId={aktive.id}
immobilieId={id}
darfSchreiben={schreiben}
stuecke={stuecke}
summeCent={summeCent ?? 0}
/>
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Building2, ClipboardList, Package, Pencil } 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 { Belastungen } from '@/components/immobilie/belastungen'
import { Kostenkonto } from '@/components/immobilie/kostenkonto'
import { StatusSozialhilfe } from '@/components/immobilie/status-sozialhilfe'
import { WertHistorie } from '@/components/immobilie/wert-historie'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum'
import {
durchschnittlicheJahreskosten,
kostenkontoAufbauen,
} from '@/lib/immobilie/kostenkonto'
import { IMMOBILIEN_KATEGORIEN } from '@/lib/immobilie/konstanten'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function ImmobilieDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/immobilie/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
const schreiben = darfSchreiben('IMMOBILIE')
const immobilie = await db.immobilie.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
belastungen: { orderBy: [{ abteilung: 'asc' }, { laufendeNr: 'asc' }] },
checklistenPunkte: true,
vermoegensposten: {
where: { art: 'IMMOBILIE', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
},
},
})
if (!immobilie) notFound()
const [buchungen, anzahlObjekte] = await Promise.all([
db.buchung.findMany({
where: {
betreuteId: aktive.id,
kategorie: { in: IMMOBILIEN_KATEGORIEN },
...nichtGeloescht,
},
select: { datum: true, betragCent: true, richtung: true, kategorie: true },
orderBy: { datum: 'desc' },
}),
db.immobilie.count({ where: { betreuteId: aktive.id, ...nichtGeloescht } }),
])
const offen = oeffnen('Immobilie', immobilie)
const jahre = kostenkontoAufbauen(buchungen)
const laufendesJahr = heuteAlsTag().getUTCFullYear()
const heute = heuteAlsTag()
const wertstaende = immobilie.vermoegensposten[0]?.wertstaende ?? []
const offenePunkte = immobilie.checklistenPunkte.filter((p) => !p.erledigt)
const ueberfaellig = offenePunkte.filter(
(p) => p.faelligAm && tageZwischen(heute, p.faelligAm) < 0,
)
const anschrift =
[immobilie.strasse, [immobilie.plz, immobilie.ort].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ') || 'Anschrift nicht erfasst'
return (
<div className="space-y-6">
<SeitenKopf
titel={immobilie.bezeichnung}
beschreibung={anschrift}
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Immobilie', href: '/immobilie' },
{ titel: immobilie.bezeichnung },
]}
aktionen={
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href={`/immobilie/${id}/checkliste`}>
<ClipboardList aria-hidden="true" />
Pflichten bei Leerstand
{ueberfaellig.length > 0 && ` (${ueberfaellig.length} überfällig)`}
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/immobilie/${id}/inventar`}>
<Package aria-hidden="true" />
Inventar
</Link>
</Button>
{schreiben && (
<Button asChild>
<Link href={`/immobilie/${id}/bearbeiten`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
)}
</div>
}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5" aria-hidden="true" />
Stammdatenblatt
</CardTitle>
<CardDescription>
Die Angaben, nach denen jede Behörde und jeder Notar als Erstes fragt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-x-8 sm:grid-cols-2">
<dl className="divide-y">
<Datenzeile beschriftung="Grundbuchamt">{immobilie.grundbuchamt ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Grundbuchblatt">{immobilie.grundbuchBlatt ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Gemarkung">{immobilie.gemarkung ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Flurstück">{immobilie.flurstueck ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Eigentumsanteil">
{immobilie.eigentumsanteil ?? '–'}
</Datenzeile>
</dl>
<dl className="divide-y">
<Datenzeile beschriftung="Wohnfläche">
{immobilie.wohnflaecheQm ? `${immobilie.wohnflaecheQm} m²` : '–'}
</Datenzeile>
<Datenzeile beschriftung="Grundstücksfläche">
{immobilie.grundstueckQm ? `${immobilie.grundstueckQm} m²` : '–'}
</Datenzeile>
<Datenzeile beschriftung="Baujahr">{immobilie.baujahr ?? '–'}</Datenzeile>
<Datenzeile beschriftung="Nutzung">
{immobilie.bewohntStatus ? (
<Badge variant={immobilie.bewohntStatus === 'leerstehend' ? 'orange' : 'outline'}>
{immobilie.bewohntStatus}
</Badge>
) : (
'–'
)}
</Datenzeile>
<Datenzeile beschriftung="Leer stehend seit">
{immobilie.leerstandSeit ? formatDatum(immobilie.leerstandSeit) : '–'}
</Datenzeile>
</dl>
{offen.notizen && (
<div className="sm:col-span-2 mt-4 rounded-md border bg-muted/40 p-3">
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</div>
)}
</CardContent>
</Card>
<Belastungen
betreuteId={aktive.id}
immobilieId={id}
darfSchreiben={schreiben}
belastungen={immobilie.belastungen.map((b) => ({
id: b.id,
art: b.art,
abteilung: b.abteilung,
laufendeNr: b.laufendeNr,
glaeubiger: b.glaeubiger,
betragCent: centZuZahl(b.betragCent),
bemerkung: b.bemerkung,
geloeschtImGrundbuchAm: b.geloeschtImGrundbuchAm?.toISOString() ?? null,
}))}
/>
<WertHistorie
betreuteId={aktive.id}
immobilieId={id}
darfSchreiben={schreiben}
werte={wertstaende.map((w) => ({
id: w.id,
stichtag: w.stichtag.toISOString(),
wertCent: centZuZahl(w.wertCent) ?? 0,
wertquelle: w.wertquelle,
bemerkung: w.bemerkung,
}))}
/>
<StatusSozialhilfe
betreuteId={aktive.id}
immobilieId={id}
darfSchreiben={schreiben}
status={immobilie.statusSozialhilfe}
begruendung={offen.statusBegruendung ?? null}
/>
<Kostenkonto
laufendesJahr={laufendesJahr}
mehrereObjekte={anzahlObjekte > 1}
durchschnittCent={centZuZahl(durchschnittlicheJahreskosten(jahre, laufendesJahr)) ?? 0}
jahre={jahre.map((jahr) => ({
jahr: jahr.jahr,
anzahlBuchungen: jahr.anzahlBuchungen,
ausgabenCent: centZuZahl(jahr.ausgabenCent) ?? 0,
einnahmenCent: centZuZahl(jahr.einnahmenCent) ?? 0,
saldoCent: centZuZahl(jahr.saldoCent) ?? 0,
ausgaben: jahr.ausgaben.map((z) => ({
kategorie: z.kategorie,
summeCent: centZuZahl(z.summeCent) ?? 0,
anzahl: z.anzahl,
})),
einnahmen: jahr.einnahmen.map((z) => ({
kategorie: z.kategorie,
summeCent: centZuZahl(z.summeCent) ?? 0,
anzahl: z.anzahl,
})),
}))}
/>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { ImmobilieFormular, LEERE_IMMOBILIE } from '@/components/immobilie/immobilie-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
export const dynamic = 'force-dynamic'
export default async function ImmobilieAnlegen() {
const benutzer = await erfordereBenutzer('/immobilie/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
if (!darfSchreiben('IMMOBILIE')) redirect('/immobilie')
return (
<div className="space-y-6">
<SeitenKopf
titel="Objekt anlegen"
beschreibung="Nimm den Grundbuchauszug zur Hand. Was du jetzt nicht weißt, lässt sich jederzeit nachtragen — nur die Bezeichnung ist Pflicht."
pfad={[
{ titel: 'Übersicht', href: '/heute' },
{ titel: 'Immobilie', href: '/immobilie' },
{ titel: 'Anlegen' },
]}
/>
<ImmobilieFormular betreuteId={aktive.id} werte={LEERE_IMMOBILIE} />
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import Link from 'next/link'
import { Building2, Plus, TriangleAlert } 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 { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum'
import {
IMMOBILIEN_STATUS_BESCHRIFTUNG,
IMMOBILIEN_STATUS_RECHTSGRUNDLAGE,
} from '@/lib/immobilie/konstanten'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function ImmobilienUebersicht() {
const benutzer = await erfordereBenutzer('/immobilie')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen')
const schreiben = darfSchreiben('IMMOBILIE')
const immobilien = await db.immobilie.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
include: {
belastungen: true,
checklistenPunkte: true,
vermoegensposten: {
where: { art: 'IMMOBILIE', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
},
},
orderBy: { erstelltAm: 'asc' },
})
const heute = heuteAlsTag()
return (
<div className="space-y-6">
<SeitenKopf
titel="Immobilie"
beschreibung="Hausgrundstücke mit Grundbuch, Belastungen, Wert-Historie und den Pflichten, die bei Leerstand weiterlaufen."
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Immobilie' }]}
aktionen={
schreiben && (
<Button asChild>
<Link href="/immobilie/neu">
<Plus aria-hidden="true" />
Objekt anlegen
</Link>
</Button>
)
}
/>
{immobilien.length === 0 ? (
<Leerzustand
titel="Noch kein Objekt erfasst"
beschreibung="Leg das Hausgrundstück an. Du brauchst dafür den Grundbuchauszug — steht er nicht in den Unterlagen, bekommst du ihn als Betreuer beim Grundbuchamt gegen Vorlage der Bestellungsurkunde."
icon={Building2}
aktion={
schreiben && (
<Button asChild>
<Link href="/immobilie/neu">
<Plus aria-hidden="true" />
Objekt anlegen
</Link>
</Button>
)
}
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
{immobilien.map((immobilie) => {
const letzterWert = immobilie.vermoegensposten[0]?.wertstaende[0] ?? null
const belastungAbtIII = immobilie.belastungen
.filter((b) => b.abteilung === 'III' && !b.geloeschtImGrundbuchAm)
.reduce((summe, b) => summe + (b.betragCent ?? 0n), 0n)
const offenePunkte = immobilie.checklistenPunkte.filter((p) => !p.erledigt)
const ueberfaellig = offenePunkte.filter(
(p) => p.faelligAm && tageZwischen(heute, p.faelligAm) < 0,
)
return (
<Card key={immobilie.id}>
<CardHeader>
<CardTitle>
<Link href={`/immobilie/${immobilie.id}`} className="hover:underline">
{immobilie.bezeichnung}
</Link>
</CardTitle>
<CardDescription>
{[immobilie.strasse, [immobilie.plz, immobilie.ort].filter(Boolean).join(' ')]
.filter(Boolean)
.join(', ') || 'Anschrift nicht erfasst'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
{immobilie.bewohntStatus && (
<Badge variant={immobilie.bewohntStatus === 'leerstehend' ? 'orange' : 'outline'}>
{immobilie.bewohntStatus}
{immobilie.leerstandSeit && ` seit ${formatDatum(immobilie.leerstandSeit)}`}
</Badge>
)}
<Badge variant="outline">
{IMMOBILIEN_STATUS_BESCHRIFTUNG[immobilie.statusSozialhilfe]}
</Badge>
</div>
<dl className="grid gap-1 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Letzter Wert</dt>
<dd className="font-medium tabular-nums">
{letzterWert
? `${formatCent(letzterWert.wertCent)} (${formatDatum(letzterWert.stichtag)})`
: 'kein Wert hinterlegt'}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Grundpfandrechte (Abt. III)</dt>
<dd className="font-medium tabular-nums">
{belastungAbtIII > 0n ? formatCent(belastungAbtIII) : '–'}
</dd>
</div>
</dl>
{ueberfaellig.length > 0 && (
<p className="flex items-start gap-2 text-sm font-medium text-ampel-rot">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{ueberfaellig.length === 1
? '1 Pflicht bei Leerstand ist überfällig.'
: `${ueberfaellig.length} Pflichten bei Leerstand sind überfällig.`}
</p>
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<Link href={`/immobilie/${immobilie.id}`}>Stammdatenblatt</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/immobilie/${immobilie.id}/checkliste`}>
Pflichten bei Leerstand
{offenePunkte.length > 0 && ` (${offenePunkte.length})`}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/immobilie/${immobilie.id}/inventar`}>Inventar</Link>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
<Rechtshinweis rechtsgrundlage={IMMOBILIEN_STATUS_RECHTSGRUNDLAGE} />
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { KeyRound, ShieldCheck } 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 { erfordereBenutzer } from '@/lib/auth/sitzung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular'
export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic'
export default async function KontoSeite() {
const benutzer = await erfordereBenutzer()
const codesUebrig = benutzer.wiederherstellungscodes.length
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Zugang</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Name</dt>
<dd className="font-medium">{benutzer.name}</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">E-Mail</dt>
<dd className="font-medium">{benutzer.email}</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Rolle</dt>
<dd className="text-right">
<Badge variant="secondary">{ROLLEN_BESCHRIFTUNG[benutzer.rolle]}</Badge>
<p className="mt-1 text-xs text-muted-foreground">
{ROLLEN_ERKLAERUNG[benutzer.rolle]}
</p>
</dd>
</div>
<div className="flex justify-between gap-4 py-2.5">
<dt className="text-sm text-muted-foreground">Letzte Anmeldung</dt>
<dd className="font-medium">{formatDatumZeit(benutzer.letzteAnmeldung)}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="size-5" aria-hidden="true" />
Zweiter Faktor
</CardTitle>
<CardDescription>
{benutzer.totpAktiviertAm
? `Eingerichtet am ${formatDatumZeit(benutzer.totpAktiviertAm)}.`
: 'Noch nicht eingerichtet.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{codesUebrig > 0 ? (
<>
Noch <strong>{codesUebrig}</strong> Wiederherstellungscode
{codesUebrig === 1 ? '' : 's'} übrig.
{codesUebrig <= 2 && (
<span className="ml-1 text-ampel-orange">
Es wird Zeit, neue zu erzeugen.
</span>
)}
</>
) : (
<span className="text-ampel-rot">
Keine Wiederherstellungscodes mehr übrig. Geht dein Handy verloren, kommst du
nicht mehr an die Daten. Bitte gleich neue erzeugen.
</span>
)}
</p>
<Button asChild variant="outline">
<Link href="/konto/zwei-faktor">Zweiten Faktor verwalten</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-5" aria-hidden="true" />
Passwort ändern
</CardTitle>
<CardDescription>
Nach dem Ändern werden alle anderen offenen Sitzungen beendet — auch auf Geräten, an
denen du gerade nicht sitzt.
</CardDescription>
</CardHeader>
<CardContent>
<PasswortFormular />
</CardContent>
</Card>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { passwortAendern, type PasswortZustand } from '@/server/actions/konto'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
)
}
export function PasswortFormular() {
const [zustand, aktion] = useActionState<PasswortZustand, FormData>(passwortAendern, {})
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>
Passwort geändert. Alle offenen Sitzungen wurden beendet — auch diese hier. Beim
nächsten Klick wirst du zur Anmeldung geschickt.
</AlertDescription>
</Alert>
)
}
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="aktuell" beschriftung="Bisheriges Passwort" fehler={zustand.felder?.aktuell} pflicht>
<Input name="aktuell" type="password" autoComplete="current-password" required />
</Feld>
<Feld
id="neu"
beschriftung="Neues Passwort"
erklaerung="Mindestens 12 Zeichen."
fehler={zustand.felder?.neu}
pflicht
>
<Input name="neu" type="password" autoComplete="new-password" required />
</Feld>
<Feld
id="wiederholung"
beschriftung="Neues Passwort wiederholen"
fehler={zustand.felder?.wiederholung}
pflicht
>
<Input name="wiederholung" type="password" autoComplete="new-password" required />
</Feld>
<Absenden />
</form>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useState, useTransition } from 'react'
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
import { wiederherstellungscodesNeu } from '@/server/actions/zwei-faktor'
import { Button } from '@/components/ui/button'
/**
* Neue Wiederherstellungscodes erzeugen.
*
* Die Aktion verlangt eine frische Anmeldung. Ist die zu lange her,
* leitet der Server zur Bestaetigungsseite um - das passiert im
* Hintergrund und braucht hier keine Behandlung.
*/
export function NeueCodes() {
const [codes, setCodes] = useState<string[] | null>(null)
const [kopiert, setKopiert] = useState(false)
const [laeuft, starten] = useTransition()
if (codes) {
return (
<div className="space-y-3">
<ul className="grid grid-cols-2 gap-2 rounded-md border bg-muted/50 p-4 font-mono text-sm">
{codes.map((code) => (
<li key={code} className="tracking-wide">
{code}
</li>
))}
</ul>
<p className="text-sm text-muted-foreground">
Diese Liste wird dir nie wieder angezeigt. Drucke sie aus und lege sie zu deinen
Betreuungsunterlagen — nicht auf dasselbe Gerät wie die Authenticator-App.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(codes.join('\n')).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Codes kopieren'}
</Button>
<Button type="button" variant="outline" onClick={() => window.print()}>
Drucken
</Button>
</div>
</div>
)
}
return (
<Button
type="button"
variant="outline"
disabled={laeuft}
onClick={() => {
starten(async () => {
setCodes(await wiederherstellungscodesNeu())
})
}}
>
{laeuft ? <Loader2 className="animate-spin" aria-hidden="true" /> : <RefreshCw aria-hidden="true" />}
{laeuft ? 'Wird erzeugt …' : 'Neue Codes erzeugen'}
</Button>
)
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { KeyRound, ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { formatDatumZeit } from '@/lib/utils'
import { NeueCodes } from './neue-codes'
export const metadata: Metadata = { title: 'Zweiter Faktor' }
export const dynamic = 'force-dynamic'
export default async function ZweiFaktorSeite() {
const benutzer = await erfordereBenutzer()
if (!benutzer.totpAktiviertAm) redirect('/zwei-faktor/einrichten')
const uebrig = benutzer.wiederherstellungscodes.length
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf
titel="Zweiter Faktor"
beschreibung="Der Code aus deiner Authenticator-App und die Wiederherstellungscodes."
pfad={[{ titel: 'Mein Konto', href: '/konto' }, { titel: 'Zweiter Faktor' }]}
/>
<Alert variant="erfolg">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Aktiv seit {formatDatumZeit(benutzer.totpAktiviertAm)}</AlertTitle>
<AlertDescription>
Jede Anmeldung verlangt zusätzlich den sechsstelligen Code. Abschalten lässt sich das
nicht — hier liegen Gesundheits- und Vermögensdaten einer Person, die nicht selbst
entscheiden kann.
</AlertDescription>
</Alert>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-5" aria-hidden="true" />
Wiederherstellungscodes
</CardTitle>
<CardDescription>
Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code
funktioniert genau einmal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm">
Noch <strong className="tabular-nums">{uebrig}</strong> von ursprünglich 8 Codes
übrig.
{uebrig <= 2 && uebrig > 0 && (
<span className="ml-1 font-medium text-ampel-orange">
Erzeuge besser neue, bevor der letzte aufgebraucht ist.
</span>
)}
{uebrig === 0 && (
<span className="ml-1 font-medium text-ampel-rot">
Ohne Codes bist du bei Verlust des Handys ausgesperrt.
</span>
)}
</p>
<Alert variant="warnung">
<AlertDescription>
Neue Codes machen die alten sofort ungültig. Für diesen Schritt brauchst du Passwort
und Code noch einmal.
</AlertDescription>
</Alert>
<NeueCodes />
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,60 @@
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AntragFormular } from '@/components/sozialhilfe/antrag-formular'
import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
export const dynamic = 'force-dynamic'
export default async function AntragBearbeitenSeite({
params,
}: {
params: Promise<{ antragId: string }>
}) {
const { antragId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext('schreiben')
if (!darfSchreiben) redirect(`/sozialhilfe/${antragId}`)
const antrag = await db.sozialhilfeAntrag.findFirst({
where: { id: antragId, betreuteId, ...nichtGeloescht },
})
if (!antrag) notFound()
const offen = oeffnen('SozialhilfeAntrag', antrag)
return (
<div className="space-y-8">
<SeitenKopf
titel="Antragsakte bearbeiten"
beschreibung="Wechselt die Sachbearbeitung oder kommt ein neuer Bewilligungszeitraum, ändere es hier — die Nachforderungen und Bescheide bleiben an der Akte."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antrag.id}` },
{ titel: 'Bearbeiten' },
]}
/>
<AntragFormular
werte={{
id: antrag.id,
traeger: antrag.traeger,
leistungsart: antrag.leistungsart,
aktenzeichen: antrag.aktenzeichen ?? '',
sachbearbeiter: antrag.sachbearbeiter ?? '',
telefon: antrag.telefon ?? '',
email: antrag.email ?? '',
antragAm: antrag.antragAm ? alsIsoTag(antrag.antragAm) : '',
eingangBeimTraegerAm: antrag.eingangBeimTraegerAm
? alsIsoTag(antrag.eingangBeimTraegerAm)
: '',
bewilligtVon: antrag.bewilligtVon ? alsIsoTag(antrag.bewilligtVon) : '',
bewilligtBis: antrag.bewilligtBis ? alsIsoTag(antrag.bewilligtBis) : '',
notizen: offen.notizen ?? '',
}}
/>
</div>
)
}
@@ -0,0 +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 { centZuEingabe } from '@/lib/sozialhilfe/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: centZuEingabe(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>
)
}
@@ -0,0 +1,47 @@
import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { BescheidFormular, LEERER_BESCHEID } from '@/components/sozialhilfe/bescheid-formular'
import { db, nichtGeloescht } from '@/lib/db'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
export const dynamic = 'force-dynamic'
/**
* Bescheid erfassen.
*
* Der Zugangsvorschlag nach § 37 Abs. 2 SGB X wird erst angeboten, wenn
* ein Bescheiddatum feststeht - und auch dann nur als Vorschlag. Wer den
* Umschlag mit dem Poststempel aufhebt, ist im Streitfall besser dran.
*/
export default async function NeuerBescheidSeite({
params,
}: {
params: Promise<{ antragId: string }>
}) {
const { antragId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext('schreiben')
if (!darfSchreiben) redirect(`/sozialhilfe/${antragId}`)
const antrag = await db.sozialhilfeAntrag.findFirst({
where: { id: antragId, betreuteId, ...nichtGeloescht },
select: { id: true, leistungsart: true, traeger: true },
})
if (!antrag) notFound()
return (
<div className="space-y-8">
<SeitenKopf
titel="Bescheid erfassen"
beschreibung={`Post vom ${antrag.traeger}. Wichtig ist der Tag, an dem der Brief im Briefkasten lag — ab da läuft die Widerspruchsfrist.`}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antrag.id}` },
{ titel: 'Neuer Bescheid' },
]}
/>
<BescheidFormular antragId={antrag.id} werte={LEERER_BESCHEID} />
</div>
)
}
@@ -0,0 +1,262 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { FileStack, Pencil, Plus, Trash2 } from 'lucide-react'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
import { ChecklistePanel } from '@/components/sozialhilfe/checkliste-panel'
import { NachforderungTracker } from '@/components/sozialhilfe/nachforderung-tracker'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { naechsterSchritt } from '@/lib/sozialhilfe/bescheide'
import { nochNichtUebernommen } from '@/lib/sozialhilfe/checkliste'
import { fristAmpel, rechtsbehelfsfristBerechnen } from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { antragLoeschen } from '@/server/actions/sozialhilfe'
import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Die Antragsakte: Stammdaten, Nachforderungs-Tracker, Bescheide.
*
* Alles, was zu einem Verfahren gehört, auf einer Seite - damit man vor
* dem Anruf beim Sachbearbeiter nicht drei Ansichten durchklicken muss.
*/
export default async function AntragsakteSeite({
params,
}: {
params: Promise<{ antragId: string }>
}) {
const { antragId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const antrag = await db.sozialhilfeAntrag.findFirst({
where: { id: antragId, betreuteId, ...nichtGeloescht },
include: {
nachforderungen: {
where: nichtGeloescht,
orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }],
include: { _count: { select: { verknuepfungen: true } } },
},
bescheide: { where: nichtGeloescht, orderBy: { bescheidVom: 'desc' } },
},
})
if (!antrag) notFound()
const offen = oeffnen('SozialhilfeAntrag', antrag)
const heute = heuteAlsTag()
const offeneChecklistenpositionen = nochNichtUebernommen(
antrag.nachforderungen.map((n) => n.bezeichnung),
)
return (
<div className="space-y-8">
<SeitenKopf
titel={antrag.leistungsart}
beschreibung={
<>
{antrag.traeger}
{antrag.aktenzeichen ? ` · Aktenzeichen ${antrag.aktenzeichen}` : ''}
{antrag.sachbearbeiter ? ` · ${antrag.sachbearbeiter}` : ''}
{antrag.telefon ? ` · ${antrag.telefon}` : ''}
</>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte' },
]}
aktionen={
darfSchreiben && (
<>
<Button variant="outline" asChild>
<Link href={`/sozialhilfe/${antrag.id}/bearbeiten`}>
<Pencil aria-hidden="true" />
Akte bearbeiten
</Link>
</Button>
<Button asChild>
<Link href={`/sozialhilfe/${antrag.id}/bescheide/neu`}>
<Plus aria-hidden="true" />
Bescheid
</Link>
</Button>
</>
)
}
/>
<Card>
<CardHeader>
<CardTitle>Verfahrensstand</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-muted-foreground">Antrag gestellt</dt>
<dd className="font-medium">{formatDatum(antrag.antragAm)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Eingang beim Träger</dt>
<dd className="font-medium">{formatDatum(antrag.eingangBeimTraegerAm)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Bewilligt von</dt>
<dd className="font-medium">{formatDatum(antrag.bewilligtVon)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Bewilligt bis</dt>
<dd className="font-medium">{formatDatum(antrag.bewilligtBis)}</dd>
</div>
</dl>
{antrag.email && (
<p className="text-sm">
<span className="text-muted-foreground">E-Mail: </span>
<a href={`mailto:${antrag.email}`} className="underline underline-offset-4">
{antrag.email}
</a>
</p>
)}
{offen.notizen && (
<div className="rounded-md border bg-muted/40 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notizen
</p>
<p className="mt-1 whitespace-pre-line text-sm">{offen.notizen}</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Leistungen setzen ein, sobald der Träger von der Notlage Kenntnis hat — nicht erst mit
der Bewilligung. Deshalb ist das Eingangsdatum so wichtig.
</p>
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" kurz />
</CardContent>
</Card>
<NachforderungTracker
antragId={antrag.id}
darfSchreiben={darfSchreiben}
eintraege={antrag.nachforderungen.map((n) => ({
id: n.id,
bezeichnung: n.bezeichnung,
erlaeuterung: n.erlaeuterung,
status: n.status,
angefordertAm: n.angefordertAm ? alsIsoTag(n.angefordertAm) : null,
fristBis: n.fristBis ? alsIsoTag(n.fristBis) : null,
uebersandtAm: n.uebersandtAm ? alsIsoTag(n.uebersandtAm) : null,
begruendungNichtVorhanden: n.begruendungNichtVorhanden,
reihenfolge: n.reihenfolge,
ausCheckliste: n.ausCheckliste,
anzahlDokumente: n._count.verknuepfungen,
}))}
/>
{darfSchreiben && (
<ChecklistePanel
antragId={antrag.id}
offen={[...offeneChecklistenpositionen]}
bereitsUebernommen={antrag.nachforderungen.filter((n) => n.ausCheckliste).length}
/>
)}
<Card>
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bescheide</CardTitle>
<CardDescription>
Maßgeblich für den Fristbeginn ist das Zugangsdatum, nicht das Datum auf dem
Bescheid.
</CardDescription>
</div>
{darfSchreiben && (
<Button variant="outline" size="sm" asChild>
<Link href={`/sozialhilfe/${antrag.id}/bescheide/neu`}>
<Plus aria-hidden="true" />
Bescheid erfassen
</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
{antrag.bescheide.length === 0 ? (
<Leerzustand
titel="Noch kein Bescheid erfasst"
beschreibung="Sobald Post vom Träger kommt: Datum des Bescheids und Tag des Zugangs eintragen. Die Widerspruchsfrist rechnet die Anwendung dann aus."
icon={FileStack}
/>
) : (
<ul className="divide-y rounded-lg border">
{antrag.bescheide.map((bescheid) => {
const frist = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
})
const ampel = fristAmpel(frist)
return (
<li key={bescheid.id} className="p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<Link
href={`/sozialhilfe/${antrag.id}/bescheide/${bescheid.id}`}
className="font-medium hover:underline"
>
{bescheid.art} vom {formatDatum(bescheid.bescheidVom)}
</Link>
<p className="text-sm text-muted-foreground">
Zugang {formatDatum(bescheid.zugegangenAm)}
{bescheid.betragCent !== null && ` · ${formatCent(bescheid.betragCent)}`}
{bescheid.zeitraumVon &&
` · Zeitraum ${formatDatum(bescheid.zeitraumVon)} – ${formatDatum(bescheid.zeitraumBis)}`}
</p>
<p className="text-sm">
{naechsterSchritt(bescheid.pruefStatus, frist.tageVerbleibend)}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<BescheidAbzeichen status={bescheid.pruefStatus} />
{bescheid.pruefStatus === 'UNGEPRUEFT' && (
<Badge variant={ampel.variante}>{ampel.text}</Badge>
)}
</div>
</div>
</li>
)
})}
</ul>
)}
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 84 SGG" kurz />
</CardContent>
</Card>
{darfSchreiben && (
<div className="border-t pt-6">
<AktionMitRueckfrage
aktion={antragLoeschen.bind(null, antrag.id)}
frage="Diese Antragsakte samt Nachforderungen und Bescheiden in den Papierkorb legen? Sie bleibt dort 30 Tage."
variant="outline"
size="default"
>
<Trash2 aria-hidden="true" />
Antragsakte löschen
</AktionMitRueckfrage>
</div>
)}
</div>
)
}
@@ -0,0 +1,318 @@
import Link from 'next/link'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { HeimkostenFormular } from '@/components/sozialhilfe/heimkosten-formular'
import { HeimkostenTabelle, type MonatsZeile } from '@/components/sozialhilfe/heimkosten-tabelle'
import { centZuEingabe } from '@/lib/sozialhilfe/betrag'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag } from '@/lib/fristen/datum'
import {
BARBETRAG_CENT,
BEKLEIDUNGSPAUSCHALE_HINWEIS,
KONSTANTEN_NAECHSTE_PRUEFUNG,
KONSTANTEN_STAND,
} from '@/lib/sozialhilfe/konstanten'
import {
heimkostenAuswerten,
jahresverlaufBilden,
leistungszuschlagProzent,
leistungszuschlagStufe,
monatsname,
pflegekassePauschaleCent,
pflegekasseRechtsgrundlage,
verweildauerMonate,
} from '@/lib/sozialhilfe/heimkosten'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils'
import { Info, TrendingDown } from 'lucide-react'
export const dynamic = 'force-dynamic'
/**
* Heimkosten-Gegenüberstellung.
*
* Die eine Zahl, um die es geht, ist die Deckungslücke: Was bleibt übrig,
* nachdem Pflegekasse, Rente und Sozialhilfe gezahlt haben? Sie steht je
* Monat und als Jahressumme da - denn eine Lücke von 180 Euro im Monat
* sind über ein Jahr mehr als zweitausend.
*/
export default async function HeimkostenSeite({
searchParams,
}: {
searchParams: Promise<{ jahr?: string }>
}) {
const { betreuteId, betreute } = await sozialhilfeKontext()
const parameter = await searchParams
const heute = heuteAlsTag()
const gewaehltesJahr = Number.parseInt(parameter.jahr ?? '', 10)
const jahr = Number.isFinite(gewaehltesJahr) ? gewaehltesJahr : heute.getUTCFullYear()
const [monate, heimvertrag] = await Promise.all([
db.heimkostenmonat.findMany({
where: { betreuteId, jahr },
orderBy: { monat: 'asc' },
}),
db.heimvertrag.findUnique({ where: { betreuteId } }),
])
const ergebnisse = monate.map((m) =>
heimkostenAuswerten({
jahr: m.jahr,
monat: m.monat,
pflegeEigenanteilCent: m.pflegeEigenanteilCent,
unterkunftVerpflegungCent: m.unterkunftVerpflegungCent,
investitionskostenCent: m.investitionskostenCent,
ausbildungsumlageCent: m.ausbildungsumlageCent,
zusatzleistungenCent: m.zusatzleistungenCent,
pflegekasseCent: m.pflegekasseCent,
leistungszuschlagCent: m.leistungszuschlagCent,
renteCent: m.renteCent,
sonstigeEinkuenfteCent: m.sonstigeEinkuenfteCent,
sozialhilfeCent: m.sozialhilfeCent,
eigenmittelCent: m.eigenmittelCent,
barbetragCent: m.barbetragCent,
bekleidungspauschaleCent: m.bekleidungspauschaleCent,
abwesenheitstage: m.abwesenheitstage,
}),
)
const verlauf = jahresverlaufBilden(jahr, ergebnisse)
const zeilen: MonatsZeile[] = Array.from({ length: 12 }, (_, i) => {
const monat = i + 1
const ergebnis = ergebnisse.find((e) => e.monat === monat)
const roh = monate.find((m) => m.monat === monat)
return {
jahr,
monat,
heimkostenCent: ergebnis?.heimkostenCent ?? 0n,
gesamtbedarfCent: ergebnis?.gesamtbedarfCent ?? 0n,
deckungCent: ergebnis?.deckungCent ?? 0n,
deckungslueckeCent: ergebnis?.deckungslueckeCent ?? 0n,
ueberdeckungCent: ergebnis?.ueberdeckungCent ?? 0n,
abwesenheitstage: roh?.abwesenheitstage ?? 0,
erfasst: ergebnis !== undefined,
}
})
// Vorschläge fürs Formular: Pauschale der Pflegekasse zum Pflegegrad
// und der gesetzliche Mindest-Barbetrag.
const pauschale = pflegekassePauschaleCent(betreute.pflegegrad)
const vorschlagPflegekasse = pauschale
? {
betrag: centZuEingabe(pauschale),
text: `Pflegegrad ${betreute.pflegegrad}: ${formatCent(pauschale)} monatlich (${pflegekasseRechtsgrundlage(betreute.pflegegrad)}, Stand ${KONSTANTEN_STAND}).`,
}
: null
const einzug = heimvertrag?.einzugAm ?? betreute.heimEinzugAm ?? null
const verweildauer = einzug ? verweildauerMonate(einzug, heute) : null
const naechsterOffenerMonat =
zeilen.find((z) => !z.erfasst && z.monat <= (jahr === heute.getUTCFullYear() ? heute.getUTCMonth() + 1 : 12))
?.monat ?? (jahr === heute.getUTCFullYear() ? heute.getUTCMonth() + 1 : 1)
const jahre = Array.from({ length: 6 }, (_, i) => heute.getUTCFullYear() - 3 + i)
return (
<div className="space-y-8">
<SeitenKopf
titel={`Heimkosten ${jahr}`}
beschreibung="Kosten gegen Deckung, Monat für Monat. Zum Bedarf zählen auch Barbetrag und Bekleidungspauschale — sie müssen der betreuten Person verbleiben."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Heimkosten' },
]}
aktionen={
<nav aria-label="Jahr wählen" className="flex flex-wrap gap-1">
{jahre.map((j) => (
<Link
key={j}
href={`/sozialhilfe/heimkosten?jahr=${j}`}
aria-current={j === jahr ? 'page' : undefined}
className={`inline-flex h-11 min-w-11 items-center justify-center rounded-md px-3 text-sm font-medium ${
j === jahr
? 'bg-primary text-primary-foreground'
: 'border border-input hover:bg-accent'
}`}
>
{j}
</Link>
))}
</nav>
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">Bedarf {jahr}</p>
<p className="text-xl font-semibold tabular-nums">
{formatCent(verlauf.summeBedarfCent)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">Deckung {jahr}</p>
<p className="text-xl font-semibold tabular-nums">
{formatCent(verlauf.summeDeckungCent)}
</p>
</CardContent>
</Card>
<Card className={verlauf.summeDeckungslueckeCent > 0n ? 'border-ampel-rot/40' : undefined}>
<CardContent className="pt-5">
<p className="text-sm text-muted-foreground">Deckungslücke {jahr}</p>
<p
className={`text-xl font-semibold tabular-nums ${
verlauf.summeDeckungslueckeCent > 0n ? 'text-ampel-rot' : 'text-ampel-gruen'
}`}
>
{formatCent(verlauf.summeDeckungslueckeCent)}
</p>
</CardContent>
</Card>
</div>
{verlauf.groessteLuecke && (
<Alert variant="warnung">
<TrendingDown aria-hidden="true" />
<AlertTitle>
Größte Lücke im {monatsname(verlauf.groessteLuecke.monat)}:{' '}
{formatCent(verlauf.groessteLuecke.deckungslueckeCent)}
</AlertTitle>
<AlertDescription>
Prüfe, ob der Bescheid für diesen Monat den vollen Bedarf abdeckt. Weicht die Rechnung
vom Bescheid ab, ist das der Anlass für eine Rückfrage beim Träger — und gegebenenfalls
für einen Widerspruch.
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Jahresverlauf</CardTitle>
<CardDescription>
Gesamtbedarf ist die Heimrechnung plus Barbetrag und Bekleidungspauschale.
</CardDescription>
</CardHeader>
<CardContent>
<HeimkostenTabelle zeilen={zeilen} />
</CardContent>
</Card>
{ergebnisse.some((e) => e.hinweise.length > 0) && (
<Card>
<CardHeader>
<CardTitle>Was auffällt</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{ergebnisse.flatMap((e) =>
e.hinweise.map((hinweis, i) => (
<li key={`${e.monat}-${i}`} className="text-sm">
<span className="font-medium">{monatsname(e.monat)}: </span>
{hinweis}
</li>
)),
)}
</ul>
</CardContent>
</Card>
)}
{ergebnisse.some((e) => e.abwesenheit.abwesenheitstage > 0) && (
<Card>
<CardHeader>
<CardTitle>Abwesenheitstage</CardTitle>
<CardDescription>
Was die Abwesenheit voraussichtlich bringt — eine Schätzung nach dem Rahmenvertrag,
nicht die Abrechnung.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{ergebnisse
.filter((e) => e.abwesenheit.abwesenheitstage > 0)
.map((e) => (
<li key={e.monat} className="text-sm">
<span className="font-medium">{monatsname(e.monat)}: </span>
{e.abwesenheit.erklaerung} Geschätzte Minderung{' '}
<span className="font-medium tabular-nums">
{formatCent(e.abwesenheit.geschaetzteMinderungCent)}
</span>
, die Lücke sänke damit auf{' '}
<span className="font-medium tabular-nums">
{formatCent(e.deckungslueckeNachAbwesenheitCent)}
</span>
.
</li>
))}
</ul>
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 87a Abs. 1 SGB XI, § 75 SGB XI" kurz />
</CardContent>
</Card>
)}
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertTitle>Woher die Vorschlagswerte kommen</AlertTitle>
<AlertDescription className="space-y-1">
<p>
Die hinterlegten Beträge haben den Stand {KONSTANTEN_STAND} und sind bis spätestens{' '}
{KONSTANTEN_NAECHSTE_PRUEFUNG} nachzuprüfen. Maßgeblich ist immer die Heimrechnung und
der Bescheid des Trägers.
</p>
{verweildauer !== null && (
<p>
Verweildauer im Heim: {verweildauer}{' '}
{verweildauer === 1 ? 'vollendeter Monat' : 'vollendete Monate'} — Leistungszuschlag{' '}
{leistungszuschlagProzent(verweildauer)} % ({leistungszuschlagStufe(verweildauer)},
§ 43c SGB XI).
</p>
)}
<p>{BEKLEIDUNGSPAUSCHALE_HINWEIS}</p>
</AlertDescription>
</Alert>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Monat erfassen oder ändern</h2>
<HeimkostenFormular
jahre={jahre}
vorschlagPflegekasse={vorschlagPflegekasse}
vorschlagBarbetrag={{
betrag: centZuEingabe(BARBETRAG_CENT),
text: `Mindestens ${formatCent(BARBETRAG_CENT)} (27 % der Regelbedarfsstufe 1, § 27b Abs. 2 SGB XII, Stand ${KONSTANTEN_STAND}). Steht im Bescheid mehr, gilt der Bescheid.`,
}}
werte={{
jahr,
monat: naechsterOffenerMonat,
pflegeEigenanteil: centZuEingabe(heimvertrag?.pflegeEigenanteilCent ?? null),
unterkunftVerpflegung: centZuEingabe(heimvertrag?.unterkunftVerpflegungCent ?? null),
investitionskosten: centZuEingabe(heimvertrag?.investitionskostenCent ?? null),
ausbildungsumlage: centZuEingabe(heimvertrag?.ausbildungsumlageCent ?? null),
zusatzleistungen: '',
pflegekasse: pauschale ? centZuEingabe(pauschale) : '',
leistungszuschlagProzent:
verweildauer !== null ? leistungszuschlagProzent(verweildauer) : 0,
leistungszuschlag: '',
rente: '',
sonstigeEinkuenfte: '',
sozialhilfe: '',
eigenmittel: '',
barbetrag: centZuEingabe(BARBETRAG_CENT),
bekleidungspauschale: '',
abwesenheitstage: 0,
bemerkung: '',
}}
/>
</section>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
</div>
)
}
@@ -0,0 +1,76 @@
import { Sparkles } from 'lucide-react'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { LeistungenListe, type LeistungZeile } from '@/components/sozialhilfe/leistungen-liste'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum'
import { katalogleistung, LEISTUNGSKATALOG, nochNichtAngelegt } from '@/lib/sozialhilfe/leistungskatalog'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
export const dynamic = 'force-dynamic'
/**
* Katalog weiterer Leistungen.
*
* Neben der Hilfe zur Pflege liegt eine Handvoll Ansprüche, die niemand
* von sich aus anbietet. Sie gehen verloren, weil man sie nicht kennt oder
* weil die jährliche Wiederholung untergeht. Das bayerische
* Landespflegegeld verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12.
* gestellt ist.
*/
export default async function LeistungenSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const leistungen = await db.leistung.findMany({
where: { betreuteId, ...nichtGeloescht },
orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }],
})
const zeilen: LeistungZeile[] = leistungen.map((l) => ({
id: l.id,
katalogSchluessel: l.katalogSchluessel,
bezeichnung: l.bezeichnung,
traeger: l.traeger ?? '',
rechtsgrundlage: l.rechtsgrundlage ?? '',
status: l.status,
beantragtAm: l.beantragtAm ? alsIsoTag(l.beantragtAm) : null,
bewilligtAm: l.bewilligtAm ? alsIsoTag(l.bewilligtAm) : null,
gueltigBis: l.gueltigBis ? alsIsoTag(l.gueltigBis) : null,
betragCent: centZuZahl(l.betragCent),
jaehrlichNeuBeantragen: l.jaehrlichNeuBeantragen,
notizen: l.notizen ?? '',
katalog: l.katalogSchluessel ? (katalogleistung(l.katalogSchluessel) ?? null) : null,
}))
const offeneKatalogeintraege = nochNichtAngelegt(leistungen.map((l) => l.katalogSchluessel))
return (
<div className="space-y-8">
<SeitenKopf
titel="Weitere Leistungen"
beschreibung="Was neben der Hilfe zur Pflege noch zusteht — mit Träger, Rechtsgrundlage und dem, was man für den Antrag braucht."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Weitere Leistungen' },
]}
/>
{zeilen.length === 0 && offeneKatalogeintraege.length === LEISTUNGSKATALOG.length && !darfSchreiben ? (
<Leerzustand
titel="Noch keine Leistungen erfasst"
beschreibung="Zum Übernehmen aus dem Katalog fehlt dir die Schreibberechtigung."
icon={Sparkles}
/>
) : (
<LeistungenListe
leistungen={zeilen}
offeneKatalogeintraege={[...offeneKatalogeintraege]}
darfSchreiben={darfSchreiben}
/>
)}
<Rechtshinweis rechtsgrundlage="§ 62 SGB V, § 152 SGB IX, § 4 RBStV, BayLPflGG" />
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AntragFormular } from '@/components/sozialhilfe/antrag-formular'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
export const dynamic = 'force-dynamic'
/**
* Neue Antragsakte.
*
* Mehrere Anträge nebeneinander sind der Normalfall: Erstantrag,
* Weiterbewilligung, dazu oft ein eigener Antrag auf Grundsicherung. Jeder
* bekommt seine eigene Akte mit eigenen Nachforderungen und Bescheiden.
*/
export default async function NeueAntragsakteSeite() {
const { darfSchreiben } = await sozialhilfeKontext('schreiben')
if (!darfSchreiben) redirect('/sozialhilfe')
return (
<div className="space-y-8">
<SeitenKopf
titel="Neue Antragsakte"
beschreibung="Trag ein, was auf dem Antrag und den Schreiben des Trägers steht. Alles Weitere — Nachforderungen, Bescheide — hängt danach an dieser Akte."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Neue Akte' },
]}
/>
<AntragFormular
werte={{
id: null,
traeger: 'Bezirk Oberfranken',
leistungsart: 'Hilfe zur Pflege nach dem 7. Kapitel SGB XII',
aktenzeichen: '',
sachbearbeiter: '',
telefon: '',
email: '',
antragAm: '',
eingangBeimTraegerAm: '',
bewilligtVon: '',
bewilligtBis: '',
notizen: '',
}}
/>
</div>
)
}
+276
View File
@@ -0,0 +1,276 @@
import Link from 'next/link'
import { Coins, FileStack, Landmark, Plus, Receipt, Sparkles } from 'lucide-react'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Fortschritt } from '@/components/ui/fortschritt'
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
import { fristAmpel, rechtsbehelfsfristBerechnen } from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic'
/**
* Einstieg ins Sozialhilfeverfahren.
*
* Oben steht, was drängt: unvollständige Nachforderungen und Bescheide,
* deren Widerspruchsfrist läuft. Erst darunter die Aktenübersicht. Die
* Seite soll die Frage "was muss ich heute tun?" beantworten, nicht "was
* habe ich alles erfasst".
*/
export default async function SozialhilfeSeite() {
const { betreuteId, betreute, darfSchreiben } = await sozialhilfeKontext()
const antraege = await db.sozialhilfeAntrag.findMany({
where: { betreuteId, ...nichtGeloescht },
include: {
nachforderungen: { where: nichtGeloescht, orderBy: { reihenfolge: 'asc' } },
bescheide: { where: nichtGeloescht, orderBy: { bescheidVom: 'desc' } },
},
orderBy: [{ antragAm: 'desc' }, { erstelltAm: 'desc' }],
})
const heute = heuteAlsTag()
// Bescheide, bei denen die Widerspruchsfrist noch läuft und niemand
// hingesehen hat. Das ist die teuerste Nachlässigkeit im Verfahren.
const offeneBescheide = antraege
.flatMap((antrag) =>
antrag.bescheide
.filter((b) => b.pruefStatus === 'UNGEPRUEFT')
.map((bescheid) => ({
antrag,
bescheid,
frist: rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate,
heute,
}),
})),
)
.sort((a, b) => (a.frist.tageVerbleibend ?? 9999) - (b.frist.tageVerbleibend ?? 9999))
return (
<div className="space-y-8">
<SeitenKopf
titel="Sozialhilfe"
beschreibung={
<>
Das Verfahren beim Sozialhilfeträger für {betreute.vorname} {betreute.nachname}:
Antragsakte, angeforderte Unterlagen, Bescheide und die Heimkosten Monat für Monat.
</>
}
pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Sozialhilfe' }]}
aktionen={
darfSchreiben && (
<Button asChild>
<Link href="/sozialhilfe/neu">
<Plus aria-hidden="true" />
Antragsakte
</Link>
</Button>
)
}
/>
{offeneBescheide.length > 0 && (
<Card className="border-ampel-rot/40">
<CardHeader>
<CardTitle>Ungeprüfte Bescheide</CardTitle>
<CardDescription>
Solange hier etwas steht, läuft eine Widerspruchsfrist ungenutzt weiter.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y rounded-lg border">
{offeneBescheide.map(({ antrag, bescheid, frist }) => {
const ampel = fristAmpel(frist)
return (
<li key={bescheid.id} className="flex flex-wrap items-center gap-3 p-3">
<Link
href={`/sozialhilfe/${antrag.id}/bescheide/${bescheid.id}`}
className="min-w-0 flex-1 font-medium hover:underline"
>
{bescheid.art} vom {formatDatum(bescheid.bescheidVom)}
<span className="block text-sm font-normal text-muted-foreground">
{antrag.traeger}
{antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''}
{frist.widerspruchMoeglichBis
? ` · Widerspruch möglich bis ${formatDatum(frist.widerspruchMoeglichBis)}`
: ' · Zugangsdatum fehlt'}
</span>
</Link>
<Badge variant={ampel.variante}>{ampel.text}</Badge>
</li>
)
})}
</ul>
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 84 SGG" kurz />
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-3">
<Bereichskachel
href="/sozialhilfe/vermoegen"
titel="Vermögen bewerten"
text="Verwertbar, geschützt oder strittig — mit Begründung für den Träger."
icon={Coins}
/>
<Bereichskachel
href="/sozialhilfe/heimkosten"
titel="Heimkosten"
text="Kosten gegen Deckung, Monat für Monat. Wo bleibt die Lücke?"
icon={Receipt}
/>
<Bereichskachel
href="/sozialhilfe/leistungen"
titel="Weitere Leistungen"
text="Landespflegegeld, Zuzahlungsbefreiung, Rundfunkbeitrag und mehr."
icon={Sparkles}
/>
</div>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Antragsakten</h2>
{antraege.length === 0 ? (
<Leerzustand
titel="Noch keine Antragsakte"
beschreibung="Leg die Akte zum Antrag beim Bezirk an. Danach kannst du die Standardunterlagen aus der Checkliste übernehmen und musst sie nicht abtippen."
icon={Landmark}
aktion={
darfSchreiben && (
<Button asChild>
<Link href="/sozialhilfe/neu">Antragsakte anlegen</Link>
</Button>
)
}
/>
) : (
<ul className="space-y-4">
{antraege.map((antrag) => {
const fortschritt = trackerFortschritt(
antrag.nachforderungen.map((n) => ({
id: n.id,
bezeichnung: n.bezeichnung,
status: n.status,
fristBis: n.fristBis,
})),
heute,
)
const letzterBescheid = antrag.bescheide[0]
return (
<li key={antrag.id}>
<Card>
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle>
<Link href={`/sozialhilfe/${antrag.id}`} className="hover:underline">
{antrag.leistungsart}
</Link>
</CardTitle>
<CardDescription>
{antrag.traeger}
{antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''}
{antrag.sachbearbeiter ? ` · ${antrag.sachbearbeiter}` : ''}
</CardDescription>
</div>
{letzterBescheid && <BescheidAbzeichen status={letzterBescheid.pruefStatus} />}
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">Antrag gestellt</dt>
<dd className="font-medium">{formatDatum(antrag.antragAm)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Eingang beim Träger</dt>
<dd className="font-medium">{formatDatum(antrag.eingangBeimTraegerAm)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Bewilligt</dt>
<dd className="font-medium">
{antrag.bewilligtVon || antrag.bewilligtBis
? `${formatDatum(antrag.bewilligtVon)} – ${formatDatum(antrag.bewilligtBis)}`
: 'noch nicht'}
</dd>
</div>
</dl>
{fortschritt.gesamt > 0 && (
<Fortschritt
wert={fortschritt.geliefert}
gesamt={fortschritt.gesamt}
beschriftung={fortschritt.beschriftung}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{fortschritt.ueberfaellig.length > 0 && (
<Badge variant="rot">
{fortschritt.ueberfaellig.length} überfällig
</Badge>
)}
{fortschritt.dieseWoche.length > 0 && (
<Badge variant="orange">
{fortschritt.dieseWoche.length} diese Woche fällig
</Badge>
)}
<Badge variant="outline">
<FileStack className="size-3" aria-hidden="true" />
{antrag.bescheide.length}{' '}
{antrag.bescheide.length === 1 ? 'Bescheid' : 'Bescheide'}
</Badge>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/sozialhilfe/${antrag.id}`}>Akte öffnen</Link>
</Button>
</CardContent>
</Card>
</li>
)
})}
</ul>
)}
</section>
<Rechtshinweis rechtsgrundlage="7. Kapitel SGB XII" />
</div>
)
}
function Bereichskachel({
href,
titel,
text,
icon: Icon,
}: {
href: string
titel: string
text: string
icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
}) {
return (
<Link
href={href}
className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon className="mb-2 size-6 text-primary" aria-hidden={true} />
<span className="block font-semibold">{titel}</span>
<span className="mt-1 block text-sm text-muted-foreground">{text}</span>
</Link>
)
}
@@ -0,0 +1,229 @@
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 { VERMOEGENSART_BESCHRIFTUNG, VERMOEGENSRELEVANTE_VERTRAGSARTEN, VERTRAGSART_BESCHRIFTUNG } from '@/lib/sozialhilfe/arten'
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,
art: { in: [...VERMOEGENSRELEVANTE_VERTRAGSARTEN] },
...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: VERMOEGENSART_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: VERTRAGSART_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>
)
}