Files
betreuungs-cockpit/src/app/(app)/finanzen/vermoegen/page.tsx
T
Florian Wilde e7006c894e feat: Konten, Kassenbuch, Vermoegen und die beiden Pflicht-PDFs
Etappe 4. Aus diesen Daten entstehen das Vermoegensverzeichnis nach
§ 1835 BGB und die Rechnungslegung nach §§ 1859, 1865 BGB - beides geht
ans Betreuungsgericht, beides ohne dass eine Zahl abgetippt wird.

Betreuungsjahr n laeuft von bestellungAm + (n-1) Jahre bis
bestellungAm + n Jahre - 1 Tag. Bei Bestellung am 29. Februar endet Jahr
1 am 27.02. und Jahr 2 beginnt am 28.02.: lueckenlos und ohne
Ueberschneidung, per Test abgesichert.

Rechenprobe der Rechnungslegung: Der Anfangsbestand ist der juengste
Saldostand bis zum VORTAG des Zeitraumbeginns - ein Saldostand gilt als
Bestand am Tagesende, sonst zaehlte der erste Tag doppelt. Geht die
Probe nicht auf, wird die Differenz im PDF ausgewiesen und jeder
fehlende Saldostand namentlich benannt. Nie geglaettet: eine still
korrigierte Rechnungslegung ist gegenueber dem Gericht wertlos.

Posten ohne Wertstand zum Stichtag erscheinen als Luecke, nicht als
0,00 EUR - und Werte aus der Zukunft werden nie herangezogen. Konten
kommen ueber ihren Saldostand ins Verzeichnis; Vermoegensposten der Art
KONTO werden dort uebersprungen, sonst stuende das Geld doppelt drin.

Der CSV- und CAMT-Import raet KEINE Kategorien, sondern bucht alles auf
"Sonstige". Eine falsch geratene Kategorie faellt in der Jahresuebersicht
niemandem auf und verfaelscht den Bericht. Fremdwaehrung wird abgelehnt
statt umgerechnet. Der XML-Leser weist DOCTYPE ab (XXE).

68 Tests, darunter echte IBAN-Pruefziffern und die Rundung: es darf
nirgends ein Cent verschwinden.

Etappe: 4
Phase: Geld
2026-08-22 06:21:45 +02:00

260 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from 'next/link'
import { Boxes, Plus } 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, Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Betrag, Hinweisliste, Kennzahl, Summenzeile } from '@/components/finanzen/bausteine'
import { Input } from '@/components/ui/input'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, tagAlsText, tagAusText } from '@/lib/finanzen/datum'
import {
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
VERMOEGENS_ART_BESCHRIFTUNG,
VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien'
import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
import type { SozialhilfeBewertung } from '@prisma/client'
export const metadata = { title: 'Vermögen' }
/**
* Vermögensposten mit ihrem Wert an einem frei wählbaren Stichtag.
*
* Der Stichtag ist hier kein Beiwerk: Genau so entsteht später das
* Vermögensverzeichnis. Wer den Stichtag zurücksetzt, sieht das
* Vermögen so, wie es damals im Verzeichnis stand – und nicht so, wie
* man es heute gerne hätte.
*/
export default async function VermoegenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const gewaehlt =
typeof parameter.stichtag === 'string' ? tagAusText(parameter.stichtag) : null
const stichtag = gewaehlt ?? heute()
const bild = await vermoegenLaden(betreute.id, stichtag)
const hinweise = bild.ohneWert.map(
(posten) =>
`„${posten.bezeichnung}“ hat zum ${formatDatum(stichtag)} keinen Wert. ` +
(posten.spaetereWertstaende > 0
? 'Es gibt nur Wertstände nach diesem Stichtag – die dürfen hier nicht verwendet werden.'
: 'Es ist noch kein Wertstand erfasst.'),
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Vermögen"
beschreibung="Grundbesitz, Versicherungen, Fahrzeuge, Forderungen und Verbindlichkeiten. Jeder Posten wird über Wertstände bewertet – nie über ein einzelnes Feld, das überschrieben wird."
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Vermögen' }]}
aktionen={
schreiben ? (
<Button asChild>
<Link href="/finanzen/vermoegen/neu">
<Plus aria-hidden="true" />
Posten anlegen
</Link>
</Button>
) : undefined
}
/>
<form method="get" className="flex flex-wrap items-end gap-3 rounded-lg border p-4">
<div className="min-w-[14rem]">
<label htmlFor="stichtag" className="mb-1.5 block text-sm font-medium">
Vermögen zum Stichtag
</label>
<Input id="stichtag" type="date" name="stichtag" defaultValue={tagAlsText(stichtag)} />
</div>
<Button type="submit" variant="secondary">
Anzeigen
</Button>
<p className="w-full text-sm text-muted-foreground">
Es wird je Posten der jüngste Wertstand verwendet, der nicht nach dem Stichtag liegt.
Spätere Bewertungen bleiben außen vor – auch dann, wenn sie genauer wären.
</p>
</form>
<div className="grid gap-4 sm:grid-cols-3">
<Kennzahl titel="Aktiva" wert={formatCent(centZuZahl(bild.summeAktivaCent))} />
<Kennzahl titel="Verbindlichkeiten" wert={formatCent(centZuZahl(bild.summePassivaCent))} />
<Kennzahl
titel="Reinvermögen"
wert={formatCent(centZuZahl(bild.reinvermoegenCent))}
hinweis="ohne Kontoguthaben – die stehen unter „Konten“"
/>
</div>
<Hinweisliste hinweise={hinweise} titel="Diese Posten fehlen im Verzeichnis" />
{bild.aktivaGruppen.length === 0 && bild.passiva.length === 0 && bild.ohneWert.length === 0 ? (
<Leerzustand
icon={Boxes}
titel="Noch kein Vermögensposten erfasst"
beschreibung="Das Hausgrundstück und die Versicherungen gehören hierher – sie machen im Vermögensverzeichnis den größten Teil aus."
aktion={
schreiben ? (
<Button asChild>
<Link href="/finanzen/vermoegen/neu">
<Plus aria-hidden="true" />
Posten anlegen
</Link>
</Button>
) : undefined
}
/>
) : (
<div className="space-y-6">
{bild.aktivaGruppen.map((gruppe) => (
<Card key={gruppe.art}>
<CardHeader>
<CardTitle>{VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}</CardTitle>
</CardHeader>
<CardContent>
<Postentabelle posten={gruppe.posten} />
<Summenzeile
beschriftung={`Summe ${VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}`}
cent={centZuZahl(gruppe.summeCent) ?? 0}
betont
/>
</CardContent>
</Card>
))}
{bild.passiva.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Verbindlichkeiten</CardTitle>
<CardDescription>
Werden mit positivem Betrag geführt; dass sie das Vermögen mindern, ergibt sich
aus der Art des Postens.
</CardDescription>
</CardHeader>
<CardContent>
<Postentabelle posten={bild.passiva} />
<Summenzeile
beschriftung="Summe Verbindlichkeiten"
cent={centZuZahl(bild.summePassivaCent) ?? 0}
betont
/>
</CardContent>
</Card>
)}
{bild.ohneWert.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ohne Bewertung zum Stichtag</CardTitle>
<CardDescription>
Diese Posten sind vorhanden, gehen aber in keine Summe ein.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{bild.ohneWert.map((posten) => (
<li key={posten.id} className="flex flex-wrap items-center gap-2">
<Link
href={`/finanzen/vermoegen/${posten.id}`}
className="font-medium hover:underline"
>
{posten.bezeichnung}
</Link>
<Badge variant="gelb">{VERMOEGENS_ART_EINZAHL[posten.art]}</Badge>
<span className="text-sm text-muted-foreground">
{posten.spaetereWertstaende > 0
? `${posten.spaetereWertstaende} Wertstand/Wertstände, alle nach dem Stichtag`
: 'noch kein Wertstand'}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 90 SGB XII" />
</div>
)
}
function Postentabelle({
posten,
}: {
posten: {
id: string
bezeichnung: string
wertCent: bigint
bewertetAm: Date
wertquelle: string
alterTage: number
nachweisTitel: string | null
bewertungSozialhilfe: string | null
}[]
}) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Posten</TableHead>
<TableHead>Wertquelle</TableHead>
<TableHead>Bewertet am</TableHead>
<TableHead>Sozialhilfe</TableHead>
<TableHead className="text-right">Wert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{posten.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link
href={`/finanzen/vermoegen/${eintrag.id}`}
className="font-medium hover:underline"
>
{eintrag.bezeichnung}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<span className="block">{eintrag.wertquelle}</span>
{eintrag.nachweisTitel && (
<span className="block">Beleg: {eintrag.nachweisTitel}</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatDatum(eintrag.bewertetAm)}
{eintrag.alterTage > 365 && (
<Badge variant="gelb" className="ml-2">
über ein Jahr alt
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{eintrag.bewertungSozialhilfe
? SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[
eintrag.bewertungSozialhilfe as SozialhilfeBewertung
]
: '–'}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(eintrag.wertCent)} fett />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}