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>
)
}
@@ -0,0 +1,170 @@
import { NextResponse } from 'next/server'
import type { Modul } from '@prisma/client'
import { protokollieren } from '@/lib/audit'
import { darf } from '@/lib/auth/rollen'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { typErkennen } from '@/lib/speicher/dateityp'
import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher'
import { zugriffTokenPruefen } from '@/lib/speicher/schluessel'
/**
* Auslieferung einer abgelegten Datei beim Dateitreiber.
*
* Hierher zeigt `signierteUrl()`, wenn kein Objektspeicher konfiguriert
* ist. Vier Schranken stehen hintereinander, und jede muss halten:
*
* 1. **Signatur** - der Link traegt einen HMAC ueber Schluessel und
* Ablauf. Ohne AUTH_SECRET laesst er sich weder faelschen noch
* verlaengern.
* 2. **Anmeldung** - ein weitergeleiteter Link nuetzt einem Fremden
* nichts, er ist nicht angemeldet.
* 3. **Berechtigung** - geprueft wird gegen die betreuteId, die im
* Schluessel steht. Wer diese Person nicht betreut, kommt nicht an
* ihre Unterlagen, auch nicht mit gueltigem Link.
* 4. **Auslieferung** - immer als Anhang, nie als HTML, mit
* X-Content-Type-Options: nosniff. Eine hochgeladene Datei darf
* niemals als Seite im Kontext dieser Anwendung ausgefuehrt werden.
*
* Jeder erfolgreiche Abruf steht danach im Audit-Log.
*/
const MODUL: Modul = 'DOKUMENTE'
/** Nur diese Typen werden ueberhaupt ausgeliefert - HTML ist nicht dabei. */
const AUSLIEFERBAR = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/tiff',
'image/heic',
])
function nurText(status: number, meldung: string): NextResponse {
return new NextResponse(meldung, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
},
})
}
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ signatur: string }> },
): Promise<NextResponse> {
const { signatur } = await params
// 1. Signatur und Ablauf
const geprueft = zugriffTokenPruefen(decodeURIComponent(signatur))
if (!geprueft.gueltig) {
return nurText(
geprueft.grund === 'abgelaufen' ? 410 : 400,
geprueft.grund === 'abgelaufen'
? 'Dieser Link ist abgelaufen. Öffne das Dokument erneut in der Akte.'
: 'Dieser Link ist ungültig.',
)
}
const schluessel = geprueft.schluessel
// 2. Angemeldet?
const benutzer = await aktuellerBenutzer()
if (!benutzer) return nurText(401, 'Bitte melde dich an.')
// 3. Zugriff auf genau diese betreute Person
const betreuteId = betreuteIdAusSchluessel(schluessel)
if (!betreuteId) return nurText(400, 'Dieser Link ist ungültig.')
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!betreute) return nurText(404, 'Nicht gefunden.')
const eintrag = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId: benutzer.id, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
const zugang = eintrag
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
: null
if (!darf(zugang, MODUL, 'lesen')) {
return nurText(403, 'Für diesen Bereich fehlt dir die Berechtigung.')
}
// Der Datensatz zur Datei - fuer Anzeigename und Protokolleintrag.
const dokument = await db.dokument.findFirst({
where: { objektSchluessel: schluessel, betreuteId },
select: { id: true, titel: true, dateiname: true, geloeschtAm: true },
})
let inhalt: Buffer
try {
inhalt = await lesen(schluessel)
} catch {
return nurText(404, 'Die Datei liegt nicht mehr im Speicher.')
}
// 4. Auslieferung: Typ am Inhalt bestimmen, nie am gespeicherten Feld.
const erkannt = typErkennen(inhalt)
const typ = erkannt && AUSLIEFERBAR.has(erkannt.mimeTyp) ? erkannt.mimeTyp : 'application/octet-stream'
const name = dateinamen(dokument?.dateiname ?? null, schluessel)
await protokollieren({
aktion: 'DOKUMENT_HERUNTERGELADEN',
entitaet: 'Dokument',
entitaetId: dokument?.id ?? null,
bezeichnung: dokument?.titel ?? name,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
return new NextResponse(new Uint8Array(inhalt), {
status: 200,
headers: {
'Content-Type': typ,
'Content-Length': String(inhalt.length),
// Niemals im Browser rendern - auch dann nicht, wenn jemand einen
// Weg gefunden haette, eine HTML-Datei hier abzulegen.
'Content-Disposition': `attachment; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Referrer-Policy': 'no-referrer',
// Private Daten gehoeren in keinen Zwischenspeicher.
'Cache-Control': 'private, no-store, max-age=0',
...(dokument?.geloeschtAm ? { 'X-Papierkorb': '1' } : {}),
},
})
}
function dateinamen(gespeichert: string | null, schluessel: string): string {
if (gespeichert && gespeichert.trim() !== '') return gespeichert
const endung = schluessel.slice(schluessel.lastIndexOf('.') + 1)
return `dokument.${endung}`
}
/** Fallback fuer Browser, die filename* nicht verstehen. */
function asciiName(name: string): string {
return (
name
.replace(/[\u00e4]/g, 'ae')
.replace(/[\u00f6]/g, 'oe')
.replace(/[\u00fc]/g, 'ue')
.replace(/[\u00c4]/g, 'Ae')
.replace(/[\u00d6]/g, 'Oe')
.replace(/[\u00dc]/g, 'Ue')
.replace(/[\u00df]/g, 'ss')
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e]/g, '_')
.replace(/["\\]/g, '_') || 'dokument'
)
}
+114
View File
@@ -0,0 +1,114 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export const metadata: Metadata = { title: 'Datenschutz' }
/**
* Datenschutzhinweise.
*
* Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man
* auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen
* fuer denselben Text waeren zwei Quellen fuer Fehler.
*
* Die Umsetzung ist bewusst schlicht: keine Markdown-Bibliothek, nur die
* wenigen Auszeichnungen, die im Dokument wirklich vorkommen.
*/
function alsAbsaetze(markdown: string) {
const zeilen = markdown.split(/\r?\n/)
const bloecke: React.ReactNode[] = []
let absatz: string[] = []
const absatzSchliessen = (schluessel: number) => {
if (absatz.length === 0) return
bloecke.push(
<p key={`p-${schluessel}`} className="leading-relaxed">
{absatz.join(' ')}
</p>,
)
absatz = []
}
zeilen.forEach((zeile, i) => {
const roh = zeile.trim()
// Der Hinweisblock fuer den Ausfuellenden gehoert nicht auf die Seite.
if (roh.startsWith('>')) return
if (roh === '' || roh === '---') {
absatzSchliessen(i)
return
}
if (roh.startsWith('## ')) {
absatzSchliessen(i)
bloecke.push(
<h2 key={i} className="mt-8 text-xl font-semibold">
{roh.slice(3)}
</h2>,
)
return
}
if (roh.startsWith('# ')) {
absatzSchliessen(i)
bloecke.push(
<h1 key={i} className="text-2xl font-semibold">
{roh.slice(2)}
</h1>,
)
return
}
if (roh.startsWith('- ')) {
absatzSchliessen(i)
bloecke.push(
<li key={i} className="ml-5 list-disc leading-relaxed">
{roh.slice(2)}
</li>,
)
return
}
// Tabellenzeilen werden als einfache Aufzaehlung dargestellt.
if (roh.startsWith('|')) {
absatzSchliessen(i)
const spalten = roh.split('|').map((s) => s.trim()).filter(Boolean)
if (spalten.every((s) => /^-+$/.test(s))) return
bloecke.push(
<div key={i} className="flex flex-col gap-0.5 border-l-2 py-1 pl-3 sm:flex-row sm:gap-3">
<span className="min-w-56 font-medium">{spalten[0]}</span>
<span className="text-muted-foreground">{spalten.slice(1).join(' · ')}</span>
</div>,
)
return
}
absatz.push(roh)
})
absatzSchliessen(zeilen.length)
return bloecke
}
export default async function DatenschutzSeite() {
const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md')
let inhalt: string
try {
inhalt = await readFile(pfad, 'utf8')
} catch {
inhalt =
'# Datenschutzhinweise\n\nDer Text konnte nicht geladen werden. ' +
'Er liegt in docs/datenschutzhinweise.md.'
}
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<article className="space-y-3 [&_strong]:font-semibold">{alsAbsaetze(inhalt)}</article>
<p className="mt-10 border-t pt-6 text-sm text-muted-foreground">
<Link href="/anmelden" className="underline hover:no-underline">
Zurück zur Anmeldung
</Link>
</p>
</div>
)
}
@@ -0,0 +1,111 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FolderArchive } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Meldung } from '@/components/berichte/meldung'
import { belegmappeAnlegen, type Aktionszustand } from '@/server/actions/berichte'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
<FolderArchive aria-hidden="true" />
{pending ? 'Belege werden zusammengelegt …' : 'Belegmappe erzeugen'}
</Button>
)
}
/**
* Auswahlmaske fuer die Belegmappe.
*
* Zwei Wege fuehren zu einer Mappe: ein Zeitraum (fuer die Rechnungslegung
* gegenueber dem Gericht) oder ein einzelner Vorgang (wenn der Bezirk
* fragt, was es mit einer bestimmten Sache auf sich hat). Das eigentliche
* Zusammenlegen der Belege macht das Dokumentenmodul - dort sitzt das
* Wissen darueber, welcher Beleg zu welcher Buchung gehoert.
*/
export function BelegmappeFormular({
betreuteId,
vorgaenge,
vorschlagVon,
vorschlagBis,
}: {
betreuteId: string
vorgaenge: { id: string; titel: string }[]
vorschlagVon: string
vorschlagBis: string
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(belegmappeAnlegen, {})
const [quelle, setQuelle] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<Feld
id="quelle"
beschriftung="Woraus soll die Mappe entstehen?"
erklaerung="Ein Zeitraum liefert die Belege zur Rechnungslegung, ein Vorgang die Belege zu einer einzelnen Sache."
pflicht
>
<Select
name="quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value as 'zeitraum' | 'vorgang')}
>
<option value="zeitraum">Alle Belege eines Zeitraums</option>
<option value="vorgang">Alle Belege zu einem Vorgang</option>
</Select>
</Feld>
{quelle === 'zeitraum' ? (
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="zeitraumVon" beschriftung="Von" fehler={zustand.felder?.zeitraumVon} pflicht>
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
</Feld>
<Feld id="zeitraumBis" beschriftung="Bis" fehler={zustand.felder?.zeitraumBis} pflicht>
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
</Feld>
</div>
) : (
<Feld
id="vorgangId"
beschriftung="Vorgang"
erklaerung="Es werden alle Dokumente aufgenommen, die mit diesem Vorgang verknüpft sind."
fehler={zustand.felder?.vorgangId}
pflicht
>
<Select name="vorgangId" required defaultValue="">
<option value="" disabled>
Bitte wählen
</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
)}
<Feld
id="titel"
beschriftung="Überschrift der Mappe"
erklaerung="Steht auf dem Deckblatt. Leer lassen genügt meistens."
fehler={zustand.felder?.titel}
>
<Input name="titel" placeholder="Belegmappe zur Rechnungslegung" maxLength={200} />
</Feld>
<Absenden />
</form>
)
}
+194
View File
@@ -0,0 +1,194 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FileDown, FileType2, RefreshCcw, Save } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Meldung } from '@/components/berichte/meldung'
import {
entwurfNeuBerechnen,
entwurfSpeichern,
jahresberichtAusgeben,
type Aktionszustand,
} from '@/server/actions/berichte'
/** Wie oft der Entwurf im Hintergrund gesichert wird, wenn sich etwas ändert. */
const AUTOSPEICHERN_MS = 60_000
function Knopf({
children,
variante = 'default',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'secondary'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Der Editor fuer den Jahresberichtsentwurf.
*
* Der wichtigste Satz dieser Seite steht oben im Kasten: Das ist ein
* Entwurf. Die Anwendung hat zusammengetragen, was sie weiss - aber sie
* war nicht dabei. Was das Gericht liest, muss der Betreuer verantworten,
* deshalb wird hier geschrieben und nicht nur bestaetigt.
*
* Zwischenspeichern geht jederzeit von Hand und laeuft zusaetzlich alle
* sechzig Sekunden von allein - ein verlorener Nachmittag Schreibarbeit
* waere die teuerste Art, Speicherplatz zu sparen.
*/
export function EntwurfEditor({
berichtId,
entwurf,
offeneStellen,
ergaenzenMarke,
hatPdf,
}: {
berichtId: string
entwurf: string
offeneStellen: number
ergaenzenMarke: string
hatPdf: boolean
}) {
const [text, setText] = React.useState(entwurf)
const [gesichert, setGesichert] = React.useState(entwurf)
const geaendert = text !== gesichert
const [speicherstand, speichern] = useActionState<Aktionszustand, FormData>(entwurfSpeichern, {})
const [ausgabestand, ausgeben] = useActionState<Aktionszustand, FormData>(jahresberichtAusgeben, {})
const [neustand, neuBerechnen] = useActionState<Aktionszustand, FormData>(entwurfNeuBerechnen, {})
const formular = React.useRef<HTMLFormElement>(null)
// Nach erfolgreichem Speichern gilt der aktuelle Text als gesichert.
React.useEffect(() => {
if (speicherstand.erfolg) setGesichert((alt) => (alt === text ? alt : text))
// Absichtlich nur am Erfolg haengend: Waehrend des Tippens soll nichts passieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speicherstand.erfolg])
// Regelmaessiges Sichern im Hintergrund.
React.useEffect(() => {
if (!geaendert) return
const uhr = setTimeout(() => formular.current?.requestSubmit(), AUTOSPEICHERN_MS)
return () => clearTimeout(uhr)
}, [geaendert, text])
// Warnung beim Verlassen mit ungesicherten Aenderungen.
React.useEffect(() => {
if (!geaendert) return
const beimVerlassen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault()
window.addEventListener('beforeunload', beimVerlassen)
return () => window.removeEventListener('beforeunload', beimVerlassen)
}, [geaendert])
const restlicheStellen = text.split(ergaenzenMarke).length - 1
return (
<div className="space-y-5">
<Alert variant="warnung">
<AlertTitle>Das ist ein Entwurf, kein fertiger Bericht</AlertTitle>
<AlertDescription>
<p>
Zusammengestellt wurde er aus deinen Kontaktprotokollen, den Gesundheitsereignissen und
den Vorgängen. Lies ihn durch, streiche, was nicht stimmt, und schreib in deinen eigenen
Worten, was nur du weißt. Abgegeben wird nichts von allein – du erzeugst das PDF, wenn
du zufrieden bist.
</p>
{restlicheStellen > 0 && (
<p className="mt-2 font-medium">
Im Text stehen noch {restlicheStellen}{' '}
{restlicheStellen === 1 ? 'Stelle' : 'Stellen'} mit „{ergaenzenMarke}“, die auf dich
warten.
</p>
)}
</AlertDescription>
</Alert>
<Meldung fehler={speicherstand.fehler} erfolg={speicherstand.erfolg} />
<Meldung fehler={ausgabestand.fehler} erfolg={ausgabestand.erfolg} />
<Meldung fehler={neustand.fehler} erfolg={neustand.erfolg} />
<form action={speichern} ref={formular} className="space-y-4">
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="entwurf" value={text} />
<Feld
id="entwurf"
beschriftung="Berichtstext"
erklaerung="Überschriften beginnen mit ihrer Nummer („3. Wünsche …“), Aufzählungen mit einem Punkt (•). Daran erkennt die Anwendung sie später beim Setzen von PDF und Word-Datei."
fehler={speicherstand.felder?.entwurf}
>
<Textarea
value={text}
onChange={(ereignis) => setText(ereignis.target.value)}
rows={30}
spellCheck
className="font-normal leading-relaxed"
/>
</Feld>
<div className="flex flex-wrap items-center gap-3">
<Knopf laeuftText="Wird gespeichert …">
<Save aria-hidden="true" />
Zwischenspeichern
</Knopf>
<p className="text-sm text-muted-foreground">
{geaendert
? 'Nicht gespeicherte Änderungen – es wird spätestens in einer Minute automatisch gesichert.'
: 'Alles gespeichert.'}
{offeneStellen > 0 && ' '}
</p>
</div>
</form>
<div className="flex flex-wrap gap-3 border-t pt-5">
<form action={ausgeben}>
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="format" value="pdf" />
<Knopf laeuftText="PDF wird gesetzt …">
<FileDown aria-hidden="true" />
{hatPdf ? 'PDF neu erzeugen' : 'PDF erzeugen'}
</Knopf>
</form>
<form action={ausgeben}>
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="format" value="docx" />
<Knopf variante="outline" laeuftText="Word-Datei wird gebaut …">
<FileType2 aria-hidden="true" />
Als Word-Datei
</Knopf>
</form>
<form
action={neuBerechnen}
onSubmit={(ereignis) => {
const sicher = window.confirm(
'Der Entwurf wird aus den aktuellen Daten neu aufgebaut. Alles, was du selbst ' +
'geschrieben hast, geht dabei verloren. Fortfahren?',
)
if (!sicher) ereignis.preventDefault()
}}
>
<input type="hidden" name="berichtId" value={berichtId} />
<Knopf variante="secondary" laeuftText="Wird neu aufgebaut …">
<RefreshCcw aria-hidden="true" />
Entwurf neu aufbauen
</Knopf>
</form>
</div>
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FileText } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Meldung } from '@/components/berichte/meldung'
import { jahresberichtAnlegen, type Aktionszustand } from '@/server/actions/berichte'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
<FileText aria-hidden="true" />
{pending ? 'Entwurf wird gebaut …' : 'Entwurf erzeugen'}
</Button>
)
}
/**
* Startet einen Jahresbericht fuer einen Berichtszeitraum.
*
* Vorbelegt ist das laufende Betreuungsjahr - es beginnt am Tag der
* Bestellung, nicht am 1. Januar. Wer einen anderen Zeitraum braucht (etwa
* fuer eine Nachforderung des Gerichts), aendert die beiden Felder.
*/
export function JahresberichtFormular({
betreuteId,
vorschlagVon,
vorschlagBis,
}: {
betreuteId: string
/** ISO-Tag, z.B. "2025-10-01". */
vorschlagVon: string
vorschlagBis: string
}) {
const router = useRouter()
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(jahresberichtAnlegen, {})
React.useEffect(() => {
if (zustand.berichtId) router.push(`/berichte/jahresbericht/${zustand.berichtId}`)
}, [zustand.berichtId, router])
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="zeitraumVon"
beschriftung="Berichtszeitraum von"
erklaerung="In der Regel der Tag, an dem das laufende Betreuungsjahr begonnen hat."
fehler={zustand.felder?.zeitraumVon}
pflicht
>
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
</Feld>
<Feld
id="zeitraumBis"
beschriftung="Berichtszeitraum bis"
erklaerung="Meist der Tag vor dem nächsten Jahrestag der Bestellung."
fehler={zustand.felder?.zeitraumBis}
pflicht
>
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
</Feld>
</div>
<Absenden />
</form>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
/**
* Die Rueckmeldung einer Server Action - Fehler, Erfolg, Warnung.
*
* Eigene Komponente, weil sie in jedem Berichtsformular gebraucht wird und
* weil sie "aria-live" tragen muss: Wer mit Screenreader arbeitet, soll
* mitbekommen, dass etwas passiert ist, ohne die Seite abzusuchen.
*/
export function Meldung({
fehler,
erfolg,
warnung,
}: {
fehler?: string
erfolg?: string
warnung?: string
}) {
if (!fehler && !erfolg && !warnung) return null
return (
<div aria-live="polite" className="space-y-2">
{fehler && (
<Alert variant="fehler">
<AlertTriangle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
{warnung && (
<Alert variant="warnung">
<Info aria-hidden="true" />
<AlertDescription>{warnung}</AlertDescription>
</Alert>
)}
{erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{erfolg}</AlertDescription>
</Alert>
)}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { ArrowDownLeft, ArrowUpRight, CircleDot, FileText, Loader2, ScanLine } from 'lucide-react'
import type { DokumentArt, DokumentRichtung, PosteingangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import { POSTEINGANG_BESCHRIFTUNG } from '@/lib/validierung/dokumente'
import { cn } from '@/lib/utils'
/**
* Die kleinen Kennzeichen an einem Dokument.
*
* Ueberall gilt: Farbe ist nie die einzige Information. Jede Ampel traegt
* ihren Text mit, sonst waere die Liste fuer farbfehlsichtige Augen leer.
*/
export function ArtAbzeichen({ art, className }: { art: DokumentArt; className?: string }) {
return (
<Badge variant="outline" className={cn('gap-1', className)}>
<FileText className="size-3" aria-hidden="true" />
{ART_BESCHRIFTUNG[art]}
</Badge>
)
}
export function RichtungAbzeichen({ richtung }: { richtung: DokumentRichtung }) {
const Icon =
richtung === 'EINGANG' ? ArrowDownLeft : richtung === 'AUSGANG' ? ArrowUpRight : CircleDot
return (
<Badge variant="secondary" className="gap-1">
<Icon className="size-3" aria-hidden="true" />
{RICHTUNG_BESCHRIFTUNG[richtung]}
</Badge>
)
}
const POSTEINGANG_VARIANTE: Record<
PosteingangStatus,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
NEU: 'orange',
GESICHTET: 'gelb',
ZUGEORDNET: 'secondary',
BEANTWORTET: 'gruen',
}
export function PosteingangAbzeichen({ status }: { status: PosteingangStatus }) {
return <Badge variant={POSTEINGANG_VARIANTE[status]}>{POSTEINGANG_BESCHRIFTUNG[status]}</Badge>
}
/**
* Der Stand der Texterkennung. "nicht verfügbar" ist ausdruecklich kein
* Fehler: auf einem Windows-Rechner ohne Tesseract ist das der Normalfall,
* und das Dokument liegt trotzdem vollstaendig in der Akte.
*/
export function OcrAbzeichen({ status }: { status: string | null }) {
if (!status || status === 'fertig') return null
const texte: Record<string, { text: string; variante: 'gelb' | 'secondary' | 'orange' }> = {
ausstehend: { text: 'Texterkennung läuft gleich', variante: 'gelb' },
laeuft: { text: 'Texterkennung läuft', variante: 'gelb' },
fehlgeschlagen: { text: 'Texterkennung fehlgeschlagen', variante: 'orange' },
nicht_verfuegbar: { text: 'ohne Volltext', variante: 'secondary' },
}
const eintrag = texte[status]
if (!eintrag) return null
const Icon = status === 'laeuft' || status === 'ausstehend' ? Loader2 : ScanLine
return (
<Badge variant={eintrag.variante} className="gap-1">
<Icon className={cn('size-3', status === 'laeuft' && 'animate-spin')} aria-hidden="true" />
{eintrag.text}
</Badge>
)
}
/** Frei gewaehlte Schlagworte. */
export function TagAbzeichen({ name }: { name: string; farbe?: string }) {
return (
<Badge variant="outline" className="font-normal">
{name}
</Badge>
)
}
@@ -0,0 +1,128 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, BookOpen, Download, Loader2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { belegmappeAnfordern, type Mappenantwort } from '@/server/actions/dokumente'
/**
* Belegmappe erzeugen: alle Belege eines Vorgangs oder eines Zeitraums als
* **eine** chronologisch sortierte PDF mit Deckblatt, Inhaltsverzeichnis
* und durchgehender Seitenzahl.
*
* Genau das erwarten Gericht und Bezirk zur Rechnungslegung - nicht
* dreissig einzelne Anhaenge. Auf die Seitenzahl der Mappe kann sich der
* Bericht dann berufen ("Beleg 7, Seite 23").
*/
export function BelegmappeFormular({ vorgaenge }: { vorgaenge: { id: string; titel: string }[] }) {
const [zustand, aktion] = useActionState<Mappenantwort, FormData>(belegmappeAnfordern, {})
const [art, setArt] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
const jahr = new Date().getFullYear()
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.url && (
<Alert variant="erfolg">
<Download aria-hidden="true" />
<AlertDescription className="space-y-2">
<p>{zustand.hinweis}</p>
<a
href={zustand.url}
className="inline-flex font-medium text-primary underline underline-offset-4"
>
{zustand.dateiname ?? 'Belegmappe'} herunterladen
</a>
<p className="text-xs text-muted-foreground">
Die Adresse gilt zehn Minuten. Danach hier neu erzeugen.
</p>
</AlertDescription>
</Alert>
)}
{zustand.hinweise && zustand.hinweise.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
{zustand.hinweise.map((hinweis, i) => (
<li key={i}>{hinweis}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="mappe-art" beschriftung="Was soll in die Mappe?">
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as 'zeitraum' | 'vorgang')}
>
<option value="zeitraum">Alle Belege eines Zeitraums</option>
<option value="vorgang">Alle Belege eines Vorgangs</option>
</Select>
</Feld>
{art === 'vorgang' ? (
<Feld id="mappe-vorgang" beschriftung="Vorgang">
<Select name="vorgangId" disabled={vorgaenge.length === 0}>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
) : (
<>
<Feld id="mappe-von" beschriftung="Von" pflicht>
<Input name="von" type="date" defaultValue={`${jahr}-01-01`} required />
</Feld>
<Feld id="mappe-bis" beschriftung="Bis" pflicht>
<Input name="bis" type="date" defaultValue={`${jahr}-12-31`} required />
</Feld>
</>
)}
</div>
<MappeKnopf gesperrt={art === 'vorgang' && vorgaenge.length === 0} />
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" kurz />
</form>
)
}
function MappeKnopf({ gesperrt }: { gesperrt: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || gesperrt}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird zusammengestellt …
</>
) : (
<>
<BookOpen aria-hidden="true" />
Belegmappe erzeugen
</>
)}
</Button>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { AlertCircle, Download, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { dokumentLoeschen, vorschauUrl } from '@/server/actions/dokumente'
/**
* Datei ansehen, herunterladen, in den Papierkorb legen.
*
* Die Datei wird nie ueber eine dauerhafte Adresse ausgeliefert. Bei jedem
* Klick wird eine frische, fuenf Minuten gueltige Adresse geholt; der
* Server prueft beim Abruf zusaetzlich, ob der angemeldete Benutzer diese
* betreute Person betreut, und schreibt einen Eintrag ins Audit-Log.
* Ein weitergereichter Link oeffnet deshalb nichts.
*/
export function DateiAktionen({
dokumentId,
istBild,
hatDatei,
schreibrecht,
}: {
dokumentId: string
istBild: boolean
hatDatei: boolean
schreibrecht: boolean
}) {
const router = useRouter()
const [laeuft, setLaeuft] = React.useState<'oeffnen' | 'vorschau' | 'loeschen' | null>(null)
const [fehler, setFehler] = React.useState<string | null>(null)
const [vorschau, setVorschau] = React.useState<string | null>(null)
async function frischeUrl(): Promise<string | null> {
const antwort = await vorschauUrl(dokumentId)
if (antwort.fehler || !antwort.url) {
setFehler(antwort.fehler ?? 'Die Datei ließ sich nicht öffnen.')
return null
}
setFehler(null)
return antwort.url
}
if (!hatDatei) {
return (
<p className="text-sm text-muted-foreground">
Zu diesem Eintrag ist keine Datei hinterlegt – nur die Angaben in der Akte.
</p>
)
}
return (
<div className="space-y-3">
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={laeuft !== null}
onClick={async () => {
setLaeuft('oeffnen')
const url = await frischeUrl()
setLaeuft(null)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
>
{laeuft === 'oeffnen' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Download aria-hidden="true" />
)}
Datei öffnen
</Button>
{istBild &&
(vorschau ? (
<Button type="button" variant="outline" onClick={() => setVorschau(null)}>
<EyeOff aria-hidden="true" />
Vorschau schließen
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={laeuft !== null}
onClick={async () => {
setLaeuft('vorschau')
const url = await frischeUrl()
setLaeuft(null)
if (url) setVorschau(url)
}}
>
{laeuft === 'vorschau' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Eye aria-hidden="true" />
)}
Vorschau anzeigen
</Button>
))}
{schreibrecht && (
<Button
type="button"
variant="outline"
disabled={laeuft !== null}
onClick={async () => {
const sicher = window.confirm(
'Dieses Dokument in den Papierkorb legen? Du kannst es 30 Tage lang zurückholen.',
)
if (!sicher) return
setLaeuft('loeschen')
const ergebnis = await dokumentLoeschen(dokumentId)
setLaeuft(null)
if (ergebnis.fehler) setFehler(ergebnis.fehler)
else router.push('/dokumente')
}}
>
{laeuft === 'loeschen' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Trash2 aria-hidden="true" />
)}
In den Papierkorb
</Button>
)}
</div>
{vorschau && (
<figure className="overflow-hidden rounded-lg border bg-muted/30">
{/* Bewusst ein einfaches <img>: die Datei wird als Anhang mit
nosniff ausgeliefert und kann so nichts ausfuehren. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={vorschau}
alt="Vorschau des hochgeladenen Dokuments"
className="max-h-[70vh] w-full object-contain"
onError={() =>
setFehler(
'Die Vorschau lässt sich in diesem Browser nicht anzeigen. Mit „Datei öffnen“ klappt es.',
)
}
/>
<figcaption className="border-t px-3 py-2 text-xs text-muted-foreground">
Vorschau – die Adresse gilt nur wenige Minuten.
</figcaption>
</figure>
)}
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import Link from 'next/link'
import { Link2, Paperclip } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatDatum } from '@/lib/utils'
import { formatBytes } from '@/lib/speicher/dateityp'
import type { Listeneintrag } from '@/lib/dokumente/suche'
import {
ArtAbzeichen,
OcrAbzeichen,
PosteingangAbzeichen,
RichtungAbzeichen,
TagAbzeichen,
} from '@/components/dokumente/abzeichen'
/**
* Die Dokumentenliste.
*
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
*/
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
return (
<>
{/* Handy */}
<ul className="space-y-3 md:hidden">
{eintraege.map((eintrag) => (
<li key={eintrag.id} className="rounded-lg border bg-card p-4">
<Link
href={`/dokumente/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
<p className="mt-1 text-sm text-muted-foreground">
{eintrag.absender ?? 'Ohne Absender'} · {formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
</p>
<Ausschnitt text={eintrag.ausschnitt} />
<div className="mt-2 flex flex-wrap gap-1.5">
<ArtAbzeichen art={eintrag.art} />
<RichtungAbzeichen richtung={eintrag.richtung} />
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
<OcrAbzeichen status={eintrag.ocrStatus} />
{eintrag.tags.map((tag) => (
<TagAbzeichen key={tag.id} name={tag.name} />
))}
</div>
</li>
))}
</ul>
{/* Tablet und Rechner */}
<div className="hidden overflow-x-auto rounded-lg border md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[38%]">Dokument</TableHead>
<TableHead>Absender</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Art</TableHead>
<TableHead>Posteingang</TableHead>
<TableHead className="text-right">Datei</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eintraege.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link
href={`/dokumente/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
{eintrag.aktenzeichen && (
<p className="text-xs text-muted-foreground">Az. {eintrag.aktenzeichen}</p>
)}
<Ausschnitt text={eintrag.ausschnitt} />
<div className="mt-1 flex flex-wrap gap-1">
{eintrag.tags.map((tag) => (
<TagAbzeichen key={tag.id} name={tag.name} />
))}
<OcrAbzeichen status={eintrag.ocrStatus} />
</div>
</TableCell>
<TableCell className="align-top">{eintrag.absender ?? '–'}</TableCell>
<TableCell className="whitespace-nowrap align-top tabular-nums">
{formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col gap-1">
<ArtAbzeichen art={eintrag.art} />
<RichtungAbzeichen richtung={eintrag.richtung} />
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col gap-1">
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
{eintrag.anzahlVerknuepfungen > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Link2 className="size-3" aria-hidden="true" />
{eintrag.anzahlVerknuepfungen} verknüpft
</span>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-right align-top text-xs text-muted-foreground">
{eintrag.dateiname ? (
<span className="inline-flex items-center gap-1">
<Paperclip className="size-3" aria-hidden="true" />
{formatBytes(eintrag.groesseBytes ?? 0)}
{eintrag.seitenzahl ? ` · ${eintrag.seitenzahl} S.` : ''}
</span>
) : (
'ohne Datei'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)
}
/**
* Die Fundstelle aus der Volltextsuche. Postgres liefert sie mit
* <mark>-Auszeichnung; hier wird sie kontrolliert wieder zusammengesetzt,
* statt fremdes HTML in die Seite zu lassen.
*/
function Ausschnitt({ text }: { text?: string }) {
if (!text) return null
const teile = text.split(/(<mark>|<\/mark>)/)
let hervorgehoben = false
return (
<p className="mt-1 text-sm text-muted-foreground">
{teile.map((teil, i) => {
if (teil === '<mark>') {
hervorgehoben = true
return null
}
if (teil === '</mark>') {
hervorgehoben = false
return null
}
return hervorgehoben ? (
<mark key={i} className="rounded bg-ampel-gelb/25 px-0.5 text-foreground">
{teil}
</mark>
) : (
<span key={i}>{teil}</span>
)
})}
</p>
)
}
@@ -0,0 +1,365 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CalendarClock, CheckCircle2, Loader2, ScanText, Sparkles } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import { DOKUMENT_ARTEN } from '@/lib/validierung/dokumente'
import {
dokumentSpeichern,
fristUebernehmen,
vorschlaegeLesen,
type Aktionszustand,
type Vorschlagsantwort,
} from '@/server/actions/dokumente'
/**
* Die Erkennungsvorschlaege zu einem frisch hochgeladenen Dokument.
*
* **Nichts hiervon wird automatisch uebernommen.** Jeder Wert steht als
* Vorschlag da, mit der Textstelle, aus der er stammt, und wird erst durch
* einen Klick verbindlich. Bei Fristen ist das keine Bequemlichkeit,
* sondern eine Sicherheitsentscheidung: eine falsch gerechnete Frist, auf
* die man sich verlaesst, ist schlimmer als gar keine.
*
* Die Texterkennung laeuft im Hintergrund weiter, waehrend diese Ansicht
* schon steht - deshalb wird ein paar Mal nachgefragt, bis ein Ergebnis da
* ist. Kommt keins (kein Tesseract auf dem Rechner), sagt die Ansicht das
* und bietet die Eingabe von Hand an.
*/
export function ErkennungPanel({
dokumentId,
vorgaenge,
}: {
dokumentId: string
vorgaenge: { id: string; titel: string }[]
}) {
const [antwort, setAntwort] = React.useState<Vorschlagsantwort | null>(null)
const [laeuft, setLaeuft] = React.useState(true)
React.useEffect(() => {
let abgebrochen = false
let versuche = 0
async function nachfragen() {
const ergebnis = await vorschlaegeLesen(dokumentId)
if (abgebrochen) return
setAntwort(ergebnis)
versuche += 1
const nochUnterwegs =
!ergebnis.fehler &&
(ergebnis.ocrStatus === 'ausstehend' || ergebnis.ocrStatus === 'laeuft') &&
versuche < 15
if (nochUnterwegs) {
// Erst schnell, dann geduldiger - ein Scan mit zwanzig Seiten
// braucht seine Zeit.
window.setTimeout(nachfragen, Math.min(1500 + versuche * 750, 6000))
} else {
setLaeuft(false)
}
}
void nachfragen()
return () => {
abgebrochen = true
}
}, [dokumentId])
if (laeuft && !antwort?.vorschlaege) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Der Text wird gelesen. Du kannst hier warten oder die Angaben gleich von Hand eintragen.
</div>
)
}
if (antwort?.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{antwort.fehler}</AlertDescription>
</Alert>
)
}
if (!antwort?.vorschlaege) {
return (
<Alert variant="hinweis">
<ScanText aria-hidden="true" />
<AlertDescription>
{antwort?.ocrStatus === 'nicht_verfuegbar'
? 'Auf diesem Rechner ist keine Texterkennung installiert (Tesseract). Das Dokument ist gespeichert – trage Absender, Datum und Aktenzeichen bitte von Hand ein.'
: 'Aus diesem Dokument ließ sich kein Text lesen. Das kommt bei schlecht belichteten Fotos vor. Trage die Angaben bitte von Hand ein.'}
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-6">
<MetadatenUebernehmen
dokumentId={dokumentId}
vorschlaege={antwort.vorschlaege}
/>
<Fristvorschlaege
dokumentId={dokumentId}
fristen={antwort.vorschlaege.fristen}
vorgaenge={vorgaenge}
/>
</div>
)
}
// -----------------------------------------------------------------------------
function MetadatenUebernehmen({
dokumentId,
vorschlaege,
}: {
dokumentId: string
vorschlaege: NonNullable<Vorschlagsantwort['vorschlaege']>
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
return (
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
{/* Wird durch die Zuordnung ohnehin gesetzt - hier gesichtet. */}
<input type="hidden" name="posteingangStatus" value="GESICHTET" />
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<h2 className="font-semibold">Das haben wir im Schreiben gelesen</h2>
<p className="text-sm text-muted-foreground">
Bitte prüfen und ändern, wo es nicht stimmt. Erst mit „Übernehmen“ wird es gespeichert.
</p>
</div>
</div>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.hinweis && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="v-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" defaultValue={vorschlaege.titel ?? ''} maxLength={200} required />
</Feld>
<Feld id="v-absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
<Input name="absender" defaultValue={vorschlaege.absender ?? ''} maxLength={200} />
</Feld>
<Feld id="v-art" beschriftung="Art">
<Select name="art" defaultValue={vorschlaege.art ?? 'SONSTIGES'}>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="v-richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={vorschlaege.richtung ?? 'EINGANG'}>
<option value="EINGANG">Eingang</option>
<option value="AUSGANG">Ausgang</option>
<option value="INTERN">Intern</option>
</Select>
</Feld>
<Feld
id="v-schreibenVom"
beschriftung="Datum des Schreibens"
erklaerung="Das Datum, das oben auf dem Brief steht."
fehler={zustand.felder?.schreibenVom}
>
<Input name="schreibenVom" type="date" defaultValue={vorschlaege.schreibenVom ?? ''} />
</Feld>
<Feld id="v-aktenzeichen" beschriftung="Aktenzeichen / Nummer">
<Input name="aktenzeichen" defaultValue={vorschlaege.aktenzeichen ?? ''} maxLength={100} />
</Feld>
</div>
<UebernehmenKnopf />
</form>
)
}
function UebernehmenKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CheckCircle2 aria-hidden="true" />}
Vorschläge übernehmen
</Button>
)
}
// -----------------------------------------------------------------------------
function Fristvorschlaege({
dokumentId,
fristen,
vorgaenge,
}: {
dokumentId: string
fristen: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen']
vorgaenge: { id: string; titel: string }[]
}) {
if (fristen.length === 0) return null
return (
<div className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex items-start gap-2">
<CalendarClock className="mt-0.5 size-5 shrink-0 text-ampel-orange" aria-hidden="true" />
<div>
<h2 className="font-semibold">
{fristen.length === 1 ? 'Mögliche Frist im Text' : `${fristen.length} mögliche Fristen im Text`}
</h2>
<p className="text-sm text-muted-foreground">
Nichts davon ist angelegt. Prüfe die Textstelle und übernimm nur, was wirklich eine
Frist ist.
</p>
</div>
</div>
{fristen.map((frist, i) => (
<Fristkarte key={i} dokumentId={dokumentId} frist={frist} vorgaenge={vorgaenge} />
))}
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 70 VwGO" kurz />
</div>
)
}
function Fristkarte({
dokumentId,
frist,
vorgaenge,
}: {
dokumentId: string
frist: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen'][number]
vorgaenge: { id: string; titel: string }[]
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(fristUebernehmen, {})
const [offen, setOffen] = React.useState(false)
if (zustand.hinweis) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)
}
return (
<div className="rounded-md border p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{frist.titel}</p>
<p className="text-sm text-muted-foreground">{frist.begruendung}</p>
</div>
{frist.rechtsbehelf && <Badge variant="rot">Rechtsbehelfsfrist</Badge>}
</div>
<blockquote className="mt-2 border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
{frist.textstelle}
</blockquote>
{zustand.fehler && (
<p className="mt-2 text-sm font-medium text-destructive">{zustand.fehler}</p>
)}
{!offen ? (
<Button type="button" variant="outline" className="mt-3" onClick={() => setOffen(true)}>
<CalendarClock aria-hidden="true" />
Als Frist übernehmen
</Button>
) : (
<form action={aktion} className="mt-3 space-y-3" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
<input type="hidden" name="beschreibung" value={frist.begruendung} />
<input type="hidden" name="typ" value={frist.rechtsbehelf ? 'GESETZLICH' : 'BEHOERDLICH'} />
{frist.rechtsbehelf && <input type="hidden" name="ausschlussfrist" value="ja" />}
<div className="grid gap-3 sm:grid-cols-2">
<Feld id={`f-titel-${dokumentId}`} beschriftung="Titel der Frist" pflicht>
<Input name="titel" defaultValue={frist.titel} maxLength={200} required />
</Feld>
<Feld
id={`f-datum-${dokumentId}`}
beschriftung="Fällig am"
erklaerung="Bitte gegen das Schreiben prüfen. Bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum."
pflicht
>
<Input name="datum" type="date" defaultValue={frist.datum ?? ''} required />
</Feld>
{vorgaenge.length > 0 && (
<Feld id={`f-vorgang-${dokumentId}`} beschriftung="Zu welchem Vorgang?">
<Select name="vorgangId" defaultValue="">
<option value="">Keinem Vorgang zuordnen</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
)}
<Feld id={`f-grundlage-${dokumentId}`} beschriftung="Rechtsgrundlage (optional)">
<Input
name="rechtsgrundlage"
defaultValue={frist.rechtsbehelf ? '§ 84 SGG' : ''}
maxLength={120}
/>
</Feld>
</div>
<div className="flex gap-2">
<FristKnopf />
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)
}
function FristKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CalendarClock aria-hidden="true" />}
Frist anlegen
</Button>
)
}
+134
View File
@@ -0,0 +1,134 @@
import Link from 'next/link'
import { Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
DOKUMENT_ARTEN,
DOKUMENT_RICHTUNGEN,
POSTEINGANG_BESCHRIFTUNG,
POSTEINGANG_STUFEN,
} from '@/lib/validierung/dokumente'
/**
* Suche und Filter ueber der Dokumentenliste.
*
* Bewusst ein schlichtes GET-Formular ohne JavaScript: die Filter stehen
* damit in der Adresszeile, lassen sich als Lesezeichen ablegen ("alle
* offenen Bescheide vom Bezirk") und funktionieren auch, wenn im Zug das
* Netz wackelt.
*/
export function FilterLeiste({
ziel = '/dokumente',
werte,
tags,
mitPosteingang = true,
}: {
ziel?: string
werte: {
suche?: string | null
art?: string | null
richtung?: string | null
status?: string | null
von?: string | null
bis?: string | null
tag?: string | null
}
tags: { id: string; name: string }[]
mitPosteingang?: boolean
}) {
const etwasGesetzt = Object.values(werte).some((wert) => wert)
return (
<form method="get" action={ziel} className="space-y-4 rounded-lg border bg-card p-4">
<Feld
id="suche"
beschriftung="Volltextsuche"
erklaerung="Sucht in Titel, Absender, Aktenzeichen und im erkannten Text der Dokumente. Mehrere Wörter suchen alle; „Wortgruppe“ in Anführungszeichen sucht genau so; ein Minus davor schließt aus."
>
<Input
name="suche"
type="search"
defaultValue={werte.suche ?? ''}
placeholder="z. B. Hilfe zur Pflege"
autoComplete="off"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Feld id="art" beschriftung="Art">
<Select name="art" defaultValue={werte.art ?? ''}>
<option value="">Alle Arten</option>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={werte.richtung ?? ''}>
<option value="">Ein- und Ausgang</option>
{DOKUMENT_RICHTUNGEN.map((richtung) => (
<option key={richtung} value={richtung}>
{RICHTUNG_BESCHRIFTUNG[richtung]}
</option>
))}
</Select>
</Feld>
{mitPosteingang && (
<Feld id="status" beschriftung="Posteingang">
<Select name="status" defaultValue={werte.status ?? ''}>
<option value="">Alle Stufen</option>
{POSTEINGANG_STUFEN.map((stufe) => (
<option key={stufe} value={stufe}>
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</option>
))}
</Select>
</Feld>
)}
<Feld id="von" beschriftung="Zeitraum von" erklaerung="Nach Eingangsdatum.">
<Input name="von" type="date" defaultValue={werte.von ?? ''} />
</Feld>
<Feld id="bis" beschriftung="Zeitraum bis">
<Input name="bis" type="date" defaultValue={werte.bis ?? ''} />
</Feld>
{tags.length > 0 && (
<Feld id="tag" beschriftung="Schlagwort">
<Select name="tag" defaultValue={werte.tag ?? ''}>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</Select>
</Feld>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
<Search aria-hidden="true" />
Suchen
</Button>
{etwasGesetzt && (
<Button type="button" variant="ghost" asChild>
<Link href={ziel}>
<X aria-hidden="true" />
Filter zurücksetzen
</Link>
</Button>
)}
</div>
</form>
)
}
@@ -0,0 +1,240 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2, Save } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
DOKUMENT_ARTEN,
DOKUMENT_RICHTUNGEN,
POSTEINGANG_BESCHRIFTUNG,
POSTEINGANG_STUFEN,
} from '@/lib/validierung/dokumente'
import { dokumentSpeichern, type Aktionszustand } from '@/server/actions/dokumente'
export type MetadatenWerte = {
id: string
titel: string
art: string
richtung: string
schreibenVom: string
eingangAm: string
versandAm: string
absender: string
empfaenger: string
aktenzeichen: string
ablageortPapier: string
wiedervorlageAm: string
ablaufAm: string
posteingangStatus: string
notizen: string
tagIds: string[]
}
/**
* Die Metadaten eines Dokuments.
*
* Das Feld, das hier am leichtesten uebersehen wird und trotzdem das
* wichtigste ist: **der Ablageort des Originals**. Diese Anwendung ersetzt
* den Leitz-Ordner nicht - sie sagt, in welchem er liegt. Wenn das Gericht
* das Original sehen will, muss man es finden.
*/
export function MetadatenFormular({
werte,
tags,
schreibrecht,
}: {
werte: MetadatenWerte
tags: { id: string; name: string }[]
schreibrecht: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
return (
<form action={aktion} className="space-y-6" noValidate>
<input type="hidden" name="dokumentId" value={werte.id} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.hinweis && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)}
<fieldset disabled={!schreibrecht} className="space-y-6">
<Feldgruppe titel="Das Schreiben">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="Woran erkennst du dieses Dokument in einem Jahr wieder?"
fehler={zustand.felder?.titel}
pflicht
className="sm:col-span-2"
>
<Input name="titel" defaultValue={werte.titel} maxLength={200} required />
</Feld>
<Feld id="art" beschriftung="Art">
<Select name="art" defaultValue={werte.art}>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={werte.richtung}>
{DOKUMENT_RICHTUNGEN.map((richtung) => (
<option key={richtung} value={richtung}>
{RICHTUNG_BESCHRIFTUNG[richtung]}
</option>
))}
</Select>
</Feld>
<Feld id="absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
<Input name="absender" defaultValue={werte.absender} maxLength={200} />
</Feld>
<Feld id="empfaenger" beschriftung="Empfänger">
<Input name="empfaenger" defaultValue={werte.empfaenger} maxLength={200} />
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen / Nummer"
erklaerung="Gerichtsaktenzeichen, Kundennummer, Versicherungsschein-Nr. – was das Schreiben eben trägt."
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} maxLength={100} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Daten"
beschreibung="Für Rechtsbehelfsfristen zählt der Eingang, nicht das Datum auf dem Brief."
>
<Feld id="schreibenVom" beschriftung="Datum des Schreibens">
<Input name="schreibenVom" type="date" defaultValue={werte.schreibenVom} />
</Feld>
<Feld
id="eingangAm"
beschriftung="Eingegangen am"
fehler={zustand.felder?.eingangAm}
erklaerung="Der Tag, an dem der Brief bei dir im Kasten lag."
>
<Input name="eingangAm" type="date" defaultValue={werte.eingangAm} />
</Feld>
<Feld id="versandAm" beschriftung="Versandt am" erklaerung="Nur bei ausgehender Post.">
<Input name="versandAm" type="date" defaultValue={werte.versandAm} />
</Feld>
<Feld
id="wiedervorlageAm"
beschriftung="Wiedervorlage am"
erklaerung="Wann willst du dieses Blatt wiedersehen?"
>
<Input name="wiedervorlageAm" type="date" defaultValue={werte.wiedervorlageAm} />
</Feld>
<Feld
id="ablaufAm"
beschriftung="Gültig bis"
erklaerung="z. B. Ablauf des Betreuerausweises oder der Zuzahlungsbefreiung."
>
<Input name="ablaufAm" type="date" defaultValue={werte.ablaufAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Ablage">
<Feld
id="ablageortPapier"
beschriftung="Ablageort des Originals"
erklaerung="Ordner und Register, in dem das Blatt in Papier liegt. Papier bleibt Papier – und das Gericht fragt danach."
className="sm:col-span-2"
>
<Input
name="ablageortPapier"
defaultValue={werte.ablageortPapier}
maxLength={150}
placeholder="Ordner 2, Register Bezirk Oberfranken"
/>
</Feld>
<Feld id="posteingangStatus" beschriftung="Stand im Posteingang">
<Select name="posteingangStatus" defaultValue={werte.posteingangStatus}>
{POSTEINGANG_STUFEN.map((stufe) => (
<option key={stufe} value={stufe}>
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</option>
))}
</Select>
</Feld>
<Feld
id="neueTags"
beschriftung="Neue Schlagworte"
erklaerung="Mit Komma getrennt, z. B. „Heimkosten, wichtig“."
>
<Input name="neueTags" maxLength={200} placeholder="Heimkosten, wichtig" />
</Feld>
{tags.length > 0 && (
<fieldset className="sm:col-span-2">
<legend className="mb-2 text-sm font-medium">Vorhandene Schlagworte</legend>
<div className="flex flex-wrap gap-3">
{tags.map((tag) => (
<label key={tag.id} className="flex min-h-11 items-center gap-2 text-sm">
<Checkbox
name="tagIds"
value={tag.id}
defaultChecked={werte.tagIds.includes(tag.id)}
/>
{tag.name}
</label>
))}
</div>
</fieldset>
)}
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Werden verschlüsselt gespeichert."
className="sm:col-span-2"
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
</Feldgruppe>
</fieldset>
{schreibrecht && <SpeichernKnopf />}
</form>
)
}
function SpeichernKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
Speichern
</Button>
)
}
@@ -0,0 +1,75 @@
'use client'
import * as React from 'react'
import { Check, Loader2 } from 'lucide-react'
import type { PosteingangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { posteingangSetzen } from '@/server/actions/dokumente'
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
import { cn } from '@/lib/utils'
/**
* Das Posteingangsbuch als Kette: NEU -> GESICHTET -> ZUGEORDNET ->
* BEANTWORTET.
*
* Diese vier Stufen sind kein Selbstzweck. Sie beantworten die Frage, die
* man sich am Kuechentisch mit dem Poststapel stellt: Was habe ich schon
* gelesen, was noch nicht, und was wartet noch auf eine Antwort? Nichts
* darf durchrutschen - dafuer gibt es diese Zeile.
*/
export function PosteingangStufen({
dokumentId,
status,
schreibrecht,
kompakt,
}: {
dokumentId: string
status: PosteingangStatus
schreibrecht: boolean
kompakt?: boolean
}) {
const [laeuft, setLaeuft] = React.useState<string | null>(null)
const [aktuell, setAktuell] = React.useState<PosteingangStatus>(status)
React.useEffect(() => setAktuell(status), [status])
const erreicht = POSTEINGANG_STUFEN.indexOf(aktuell)
return (
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label="Stand im Posteingang"
>
{POSTEINGANG_STUFEN.map((stufe, i) => {
const istAktuell = stufe === aktuell
const istErledigt = i < erreicht
return (
<Button
key={stufe}
type="button"
size={kompakt ? 'sm' : 'default'}
variant={istAktuell ? 'default' : istErledigt ? 'secondary' : 'outline'}
disabled={!schreibrecht || laeuft !== null || istAktuell}
aria-current={istAktuell ? 'step' : undefined}
className={cn(!schreibrecht && 'pointer-events-none')}
onClick={async () => {
setLaeuft(stufe)
const ergebnis = await posteingangSetzen(dokumentId, stufe)
if (!ergebnis.fehler) setAktuell(stufe)
setLaeuft(null)
}}
>
{laeuft === stufe ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : istErledigt ? (
<Check aria-hidden="true" />
) : null}
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</Button>
)
})}
</div>
)
}
@@ -0,0 +1,298 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
AlertCircle,
Camera,
CheckCircle2,
FileUp,
Loader2,
Trash2,
Upload,
} from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { ACCEPT_ATTRIBUT, formatBytes } from '@/lib/speicher/dateityp'
import { dokumenteHochladen, type Aktionszustand } from '@/server/actions/dokumente'
import { ErkennungPanel } from '@/components/dokumente/erkennung-panel'
import { cn } from '@/lib/utils'
/**
* Dokumente aufnehmen: per Drag&Drop, per Dateiauswahl oder direkt mit der
* Handykamera.
*
* Das Ziel dieser Seite in einem Satz: **Foto hochladen, in zwei Klicks
* einem Vorgang zuordnen, daraus eine Frist erzeugen.** Alles andere ist
* Beiwerk. Deshalb sind Titel, Art und Absender optional - was der Brief
* hergibt, schlaegt die Erkennung anschliessend selbst vor.
*/
export function UploadFormular({
vorgaenge,
vorgangVorgewaehlt,
kameraZuerst,
}: {
vorgaenge: { id: string; titel: string }[]
vorgangVorgewaehlt?: string | null
/** Aufruf aus der Schnellerfassung "Beleg fotografieren". */
kameraZuerst?: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumenteHochladen, {})
const [dateien, setDateien] = React.useState<File[]>([])
const [ueberzogen, setUeberzogen] = React.useState(false)
const dateiFeld = React.useRef<HTMLInputElement>(null)
const kameraFeld = React.useRef<HTMLInputElement>(null)
/**
* Die ausgewaehlten Dateien werden in einem eigenen Zustand gehalten und
* vor dem Absenden in das versteckte Feld gespiegelt. Nur so lassen sich
* Dateiauswahl, Kamera und Drag&Drop zu einem Stapel zusammenfuehren.
*/
const uebernehmen = React.useCallback((neue: FileList | null) => {
if (!neue || neue.length === 0) return
setDateien((bisher) => {
const zusammen = [...bisher]
for (const datei of Array.from(neue)) {
const schonDa = zusammen.some(
(d) => d.name === datei.name && d.size === datei.size && d.lastModified === datei.lastModified,
)
if (!schonDa) zusammen.push(datei)
}
return zusammen.slice(0, 30)
})
}, [])
React.useEffect(() => {
if (!dateiFeld.current) return
const behaelter = new DataTransfer()
for (const datei of dateien) behaelter.items.add(datei)
dateiFeld.current.files = behaelter.files
}, [dateien])
// Nach einem erfolgreichen Stapel die Auswahl leeren.
React.useEffect(() => {
if (zustand.ergebnisse && zustand.ergebnisse.some((e) => e.dokumentId)) {
setDateien([])
}
}, [zustand.ergebnisse])
const gesamt = dateien.reduce((summe, datei) => summe + datei.size, 0)
const einzelnHochgeladen =
zustand.ergebnisse?.filter((e) => e.dokumentId).length === 1
? zustand.ergebnisse.find((e) => e.dokumentId)!.dokumentId!
: null
return (
<div className="space-y-6">
<form action={aktion} className="space-y-6" noValidate>
<input
ref={dateiFeld}
type="file"
name="dateien"
multiple
accept={ACCEPT_ATTRIBUT}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(ereignis) => uebernehmen(ereignis.target.files)}
/>
{/* Getrenntes Feld fuer die Kamera: capture darf nicht am Sammelfeld
haengen, sonst bietet das Handy keine Dateiauswahl mehr an. */}
<input
ref={kameraFeld}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(ereignis) => {
uebernehmen(ereignis.target.files)
ereignis.target.value = ''
}}
/>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div
onDragOver={(ereignis) => {
ereignis.preventDefault()
setUeberzogen(true)
}}
onDragLeave={() => setUeberzogen(false)}
onDrop={(ereignis) => {
ereignis.preventDefault()
setUeberzogen(false)
uebernehmen(ereignis.dataTransfer.files)
}}
className={cn(
'rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-10',
ueberzogen ? 'border-primary bg-primary/5' : 'border-border',
)}
>
<FileUp className="mx-auto mb-3 size-8 text-muted-foreground" aria-hidden="true" />
<p className="font-medium">Dokumente hierher ziehen</p>
<p className="mt-1 text-sm text-muted-foreground">
PDF, JPEG, PNG, WEBP, TIFF oder HEIC. Mehrere auf einmal sind ausdrücklich erwünscht.
</p>
<div className="mt-5 flex flex-col justify-center gap-2 sm:flex-row">
<Button
type="button"
variant={kameraZuerst ? 'default' : 'outline'}
size="lg"
onClick={() => kameraFeld.current?.click()}
>
<Camera aria-hidden="true" />
Foto aufnehmen
</Button>
<Button
type="button"
variant={kameraZuerst ? 'outline' : 'default'}
size="lg"
onClick={() => dateiFeld.current?.click()}
>
<Upload aria-hidden="true" />
Dateien auswählen
</Button>
</div>
</div>
{dateien.length > 0 && (
<div className="rounded-lg border">
<div className="flex items-center justify-between border-b px-4 py-2.5 text-sm">
<span className="font-medium">
{dateien.length} {dateien.length === 1 ? 'Datei' : 'Dateien'} · {formatBytes(gesamt)}
</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setDateien([])}>
Auswahl leeren
</Button>
</div>
<ul className="divide-y">
{dateien.map((datei, i) => (
<li key={`${datei.name}-${i}`} className="flex items-center gap-3 px-4 py-2.5">
<span className="min-w-0 flex-1 truncate text-sm">{datei.name}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatBytes(datei.size)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setDateien((bisher) => bisher.filter((_, index) => index !== i))}
aria-label={`${datei.name} aus der Auswahl entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="Nur bei einer einzelnen Datei. Leer lassen ist in Ordnung – die Erkennung schlägt gleich einen Titel vor."
fehler={zustand.felder?.titel}
>
<Input name="titel" maxLength={200} />
</Feld>
<Feld
id="eingangAm"
beschriftung="Eingegangen am"
erklaerung="Maßgeblich für Rechtsbehelfsfristen – nicht das Datum auf dem Brief. Leer = heute."
fehler={zustand.felder?.eingangAm}
>
<Input name="eingangAm" type="date" />
</Feld>
<Feld id="vorgangId" beschriftung="Direkt einem Vorgang zuordnen">
<Select name="vorgangId" defaultValue={vorgangVorgewaehlt ?? ''}>
<option value="">Später zuordnen</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
<Feld
id="ablageortPapier"
beschriftung="Ablageort des Originals"
erklaerung="Wo liegt das Blatt in Papier? z. B. „Ordner 2, Register Bezirk“. Papier bleibt Papier."
>
<Input name="ablageortPapier" maxLength={150} placeholder="Ordner 2, Register Bezirk" />
</Feld>
</div>
<Absendeknopf anzahl={dateien.length} />
</form>
{zustand.ergebnisse && zustand.ergebnisse.length > 0 && (
<div className="space-y-3">
<Alert variant={zustand.ergebnisse.every((e) => e.dokumentId) ? 'erfolg' : 'warnung'}>
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis ?? 'Verarbeitet.'}</AlertDescription>
</Alert>
<ul className="divide-y rounded-lg border">
{zustand.ergebnisse.map((ergebnis, i) => (
<li key={i} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<span className="min-w-0 flex-1 truncate">{ergebnis.dateiname}</span>
{ergebnis.dokumentId ? (
<Link
href={`/dokumente/${ergebnis.dokumentId}`}
className="shrink-0 font-medium text-primary hover:underline"
>
Öffnen
</Link>
) : (
<span className="text-destructive">{ergebnis.fehler}</span>
)}
</li>
))}
</ul>
</div>
)}
{einzelnHochgeladen && (
<ErkennungPanel dokumentId={einzelnHochgeladen} vorgaenge={vorgaenge} />
)}
</div>
)
}
function Absendeknopf({ anzahl }: { anzahl: number }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={pending || anzahl === 0}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird übernommen …
</>
) : (
<>
<Upload aria-hidden="true" />
{anzahl <= 1 ? 'In die Akte übernehmen' : `${anzahl} Dokumente übernehmen`}
</>
)}
</Button>
)
}
+178
View File
@@ -0,0 +1,178 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Link2, Link2Off, Loader2, Plus } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { ZIEL_BESCHRIFTUNG, type Verknuepfungsziel } from '@/lib/validierung/dokumente'
import {
dokumentVerknuepfen,
verknuepfungLoesen,
type Aktionszustand,
} from '@/server/actions/dokumente'
export type Verknuepfungseintrag = {
id: string
ziel: Verknuepfungsziel
zielId: string
bezeichnung: string
bemerkung: string | null
/** Wohin fuehrt der Link in der Anwendung? */
href: string | null
}
export type Zielauswahl = {
ziel: Verknuepfungsziel
eintraege: { id: string; titel: string }[]
}
/**
* Verknuepfungen eines Dokuments mit der uebrigen Akte.
*
* Ein Bescheid gehoert zum Vorgang "Hilfe zur Pflege", begruendet eine
* Widerspruchsfrist und belegt eine Buchung. Erst diese Verbindungen
* machen aus einem Stapel Dateien eine Akte, in der man einen Sachverhalt
* von jeder Seite aus wiederfindet.
*/
export function Verknuepfungen({
dokumentId,
vorhandene,
auswahl,
schreibrecht,
}: {
dokumentId: string
vorhandene: Verknuepfungseintrag[]
auswahl: Zielauswahl[]
schreibrecht: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentVerknuepfen, {})
const [gewaehlt, setGewaehlt] = React.useState<Verknuepfungsziel>(auswahl[0]?.ziel ?? 'vorgang')
const moeglich = auswahl.find((eintrag) => eintrag.ziel === gewaehlt)?.eintraege ?? []
return (
<div className="space-y-4">
{vorhandene.length === 0 ? (
<Leerzustand
icon={Link2}
titel="Noch nicht zugeordnet"
beschreibung="Ordne das Dokument einem Vorgang zu – dann taucht es dort auf, wo du es suchst."
/>
) : (
<ul className="divide-y rounded-lg border">
{vorhandene.map((eintrag) => (
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{ZIEL_BESCHRIFTUNG[eintrag.ziel]}
</span>
<span className="min-w-0 flex-1">
{eintrag.href ? (
<Link href={eintrag.href} className="font-medium hover:underline">
{eintrag.bezeichnung}
</Link>
) : (
<span className="font-medium">{eintrag.bezeichnung}</span>
)}
{eintrag.bemerkung && (
<span className="block text-sm text-muted-foreground">{eintrag.bemerkung}</span>
)}
</span>
{schreibrecht && <LoesenKnopf verknuepfungId={eintrag.id} />}
</li>
))}
</ul>
)}
{schreibrecht && (
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="ziel" beschriftung="Womit verknüpfen?">
<Select
name="ziel"
value={gewaehlt}
onChange={(ereignis) => setGewaehlt(ereignis.target.value as Verknuepfungsziel)}
>
{auswahl.map((eintrag) => (
<option key={eintrag.ziel} value={eintrag.ziel}>
{ZIEL_BESCHRIFTUNG[eintrag.ziel]} ({eintrag.eintraege.length})
</option>
))}
</Select>
</Feld>
<Feld
id="zielId"
beschriftung={ZIEL_BESCHRIFTUNG[gewaehlt]}
erklaerung={moeglich.length === 0 ? 'Hier gibt es noch nichts zum Verknüpfen.' : undefined}
>
<Select name="zielId" disabled={moeglich.length === 0}>
{moeglich.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.titel}
</option>
))}
</Select>
</Feld>
<Feld id="bemerkung" beschriftung="Bemerkung (optional)" className="sm:col-span-2">
<Input name="bemerkung" maxLength={200} placeholder="z. B. Beleg zur Zahlung vom 12.03." />
</Feld>
</div>
<VerknuepfenKnopf gesperrt={moeglich.length === 0} />
</form>
)}
</div>
)
}
function VerknuepfenKnopf({ gesperrt }: { gesperrt: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || gesperrt}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Plus aria-hidden="true" />}
Verknüpfen
</Button>
)
}
function LoesenKnopf({ verknuepfungId }: { verknuepfungId: string }) {
const [laeuft, setLaeuft] = React.useState(false)
return (
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={async () => {
setLaeuft(true)
await verknuepfungLoesen(verknuepfungId)
setLaeuft(false)
}}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Link2Off aria-hidden="true" />
)}
Lösen
</Button>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import type { Formularzustand } from '@/server/actions/finanzen'
type Aktion = (zustand: Formularzustand, daten: FormData) => Promise<Formularzustand>
/**
* Löschknopf mit Rückfrage.
*
* Gelöscht wird im Papierkorb-Sinn: geloeschtAm wird gesetzt, der
* Datensatz bleibt 30 Tage bestehen. Bei Saldoständen und Wertständen
* ist es dagegen ein echtes Entfernen – deshalb steht bei denen ein
* deutlicherer Text.
*/
export function Loeschknopf({
aktion,
felder,
beschriftung = 'Löschen',
frage,
variante = 'ghost',
}: {
aktion: Aktion
felder: Record<string, string>
beschriftung?: string
frage: string
variante?: 'ghost' | 'destructive' | 'outline'
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(aktion, {})
const [gefragt, setGefragt] = React.useState(false)
if (!gefragt) {
return (
<div className="space-y-2">
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Button type="button" variant={variante} size="sm" onClick={() => setGefragt(true)}>
<Trash2 aria-hidden="true" />
{beschriftung}
</Button>
</div>
)
}
return (
<form action={absenden} className="space-y-2 rounded-md border border-destructive/50 p-3">
{Object.entries(felder).map(([name, wert]) => (
<input key={name} type="hidden" name={name} value={wert} />
))}
<p className="text-sm">{frage}</p>
<div className="flex gap-2">
<Absendeknopf variant="destructive" size="sm" wartetext="Wird gelöscht …">
Ja, löschen
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
+206
View File
@@ -0,0 +1,206 @@
import * as React from 'react'
import Link from 'next/link'
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils'
/**
* Kleine Bausteine, die in mehreren Finanzseiten vorkommen.
*
* Alle rein darstellend und ohne Zustand - sie laufen deshalb sowohl in
* Server- als auch in Client-Komponenten. Betraege kommen hier immer als
* Number in Cent an; die Umwandlung aus BigInt passiert vorher mit
* centZuZahl().
*/
/**
* Ein Geldbetrag. Ausgaben werden mit Minuszeichen UND in gedeckter
* Farbe gezeigt - die Farbe allein waere fuer farbfehlsichtige Nutzer
* keine Information.
*/
export function Betrag({
cent,
richtung,
fett,
className,
}: {
cent: number | null
richtung?: 'EINNAHME' | 'AUSGABE' | 'UMBUCHUNG'
fett?: boolean
className?: string
}) {
if (cent === null) {
return <span className={cn('tabular-nums text-muted-foreground', className)}>nicht erfasst</span>
}
const vorzeichenbehaftet = richtung === 'AUSGABE' ? -Math.abs(cent) : cent
const negativ = vorzeichenbehaftet < 0
return (
<span
className={cn(
'tabular-nums',
fett && 'font-semibold',
negativ ? 'text-ampel-rot' : undefined,
className,
)}
>
{formatCent(vorzeichenbehaftet)}
</span>
)
}
/** Kachel mit einer Zahl und einer Erklärung, was sie bedeutet. */
export function Kennzahl({
titel,
wert,
hinweis,
ton = 'neutral',
href,
}: {
titel: string
wert: React.ReactNode
hinweis?: React.ReactNode
ton?: 'neutral' | 'gut' | 'achtung' | 'schlecht'
href?: string
}) {
const rahmen =
ton === 'gut'
? 'border-ampel-gruen/40'
: ton === 'achtung'
? 'border-ampel-orange/50'
: ton === 'schlecht'
? 'border-ampel-rot/50'
: undefined
const inhalt = (
<CardContent className="space-y-1 p-4">
<p className="text-sm font-medium text-muted-foreground">{titel}</p>
<p className="text-2xl font-semibold tabular-nums">{wert}</p>
{hinweis && <p className="text-sm text-muted-foreground">{hinweis}</p>}
</CardContent>
)
if (href) {
return (
<Card className={cn('transition-colors hover:bg-accent/40', rahmen)}>
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{inhalt}
</Link>
</Card>
)
}
return <Card className={rahmen}>{inhalt}</Card>
}
/**
* Belegzustand einer Buchung. Der Belegzwang verbietet nichts - er macht
* sichtbar. Eine Buchung ohne Beleg und ohne Begruendung ist die, nach
* der beim Rechtspfleger gefragt wird.
*/
export function Belegzeichen({
belegTitel,
begruendung,
belegId,
}: {
belegId: string | null
belegTitel: string | null
begruendung: string | null
}) {
if (belegId) {
return (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Paperclip className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{belegTitel ?? 'Beleg hinterlegt'}</span>
</span>
)
}
if (begruendung && begruendung.trim() !== '') {
return (
<Badge variant="gelb" title={begruendung}>
<FileWarning className="size-3.5" aria-hidden="true" />
Beleg begründet fehlend
</Badge>
)
}
return (
<Badge variant="rot">
<AlertTriangle className="size-3.5" aria-hidden="true" />
Beleg fehlt
</Badge>
)
}
/**
* Die Warnung, um die es bei der Trennung der Vermoegensmassen geht:
* eine Buchung auf einem Konto des Betreuers, die trotzdem als Vermoegen
* der betreuten Person gefuehrt wird.
*/
export function Vermischungswarnung({ kurz = false }: { kurz?: boolean }) {
if (kurz) {
return (
<Badge variant="rot">
<AlertTriangle className="size-3.5" aria-hidden="true" />
Vermögen vermischt
</Badge>
)
}
return (
<div className="flex items-start gap-2.5 rounded-md border border-ampel-rot/50 bg-ampel-rot/10 p-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-ampel-rot" aria-hidden="true" />
<div className="space-y-1 text-sm">
<p className="font-medium">Vorsicht: hier vermischen sich zwei Vermögen.</p>
<p className="text-muted-foreground">
Die Buchung läuft über ein Konto, das als <strong>Konto des Betreuers</strong> markiert
ist, gilt aber als Vermögen der betreuten Person. Genau das fällt einem in der
Rechnungslegung um die Ohren. Entweder ist das Konto falsch markiert, oder es ist eine
eigene Auslage – dann bitte den Haken bei „betrifft das Vermögen der betreuten Person“
entfernen.
</p>
</div>
</div>
)
}
/** Zeile in einer Bestands- oder Summenaufstellung. */
export function Summenzeile({
beschriftung,
cent,
betont,
}: {
beschriftung: React.ReactNode
cent: number
betont?: boolean
}) {
return (
<div
className={cn(
'flex items-baseline justify-between gap-4 border-t py-2',
betont && 'border-foreground/40 font-semibold',
)}
>
<span>{beschriftung}</span>
<Betrag cent={cent} fett={betont} />
</div>
)
}
/** Hinweise aus einer Berechnung - Lücken werden benannt, nicht versteckt. */
export function Hinweisliste({ hinweise, titel }: { hinweise: string[]; titel?: string }) {
if (hinweise.length === 0) return null
return (
<div className="rounded-md border border-ampel-orange/50 bg-ampel-orange/10 p-3">
<p className="mb-1.5 text-sm font-medium">{titel ?? 'Das solltest du vor der Abgabe klären'}</p>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{hinweise.map((hinweis, i) => (
<li key={i}>{hinweis}</li>
))}
</ul>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Filter, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import {
ALLE_KATEGORIEN,
BUCHUNG_RICHTUNGEN,
KATEGORIE_BESCHRIFTUNG,
RICHTUNG_BESCHRIFTUNG,
} from '@/lib/finanzen/kategorien'
/**
* Filter der Buchungsliste.
*
* Der Zustand steht in der Adresszeile, nicht in einem React-State: So
* lässt sich eine gefilterte Ansicht als Lesezeichen ablegen („alle
* Heimkosten 2026“) und beim Zurückgehen bleibt sie erhalten.
*/
export function Buchungsfilter({
konten,
aktiv,
}: {
konten: { id: string; bezeichnung: string }[]
aktiv: {
kontoId: string
kategorie: string
richtung: string
von: string
bis: string
suche: string
nurOhneBeleg: boolean
nurEigeneAuslagen: boolean
}
}) {
const router = useRouter()
const parameter = useSearchParams()
const [offen, setOffen] = React.useState(
Boolean(
aktiv.kategorie ||
aktiv.richtung ||
aktiv.von ||
aktiv.bis ||
aktiv.nurOhneBeleg ||
aktiv.nurEigeneAuslagen,
),
)
function anwenden(formular: FormData) {
const neu = new URLSearchParams()
for (const [schluessel, wert] of formular.entries()) {
if (typeof wert === 'string' && wert !== '') neu.set(schluessel, wert)
}
router.push(`/finanzen/buchungen?${neu.toString()}`)
}
const anzahlAktiv = [
aktiv.kontoId,
aktiv.kategorie,
aktiv.richtung,
aktiv.von,
aktiv.bis,
aktiv.suche,
aktiv.nurOhneBeleg ? 'x' : '',
aktiv.nurEigeneAuslagen ? 'x' : '',
].filter(Boolean).length
return (
<form action={anwenden} className="space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[16rem] flex-1">
<Feld
id="suche"
beschriftung="Suche"
erklaerung="Sucht in Gegenstelle und Kategorie. Der Verwendungszweck ist verschlüsselt und lässt sich nicht durchsuchen."
>
<Input name="suche" defaultValue={aktiv.suche} placeholder="Seniowohnpark, Rente …" />
</Feld>
</div>
<div className="min-w-[14rem]">
<Feld id="kontoId" beschriftung="Konto">
<Select name="kontoId" defaultValue={aktiv.kontoId}>
<option value="">alle Konten</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
</div>
<Button type="submit" variant="secondary">
<Filter aria-hidden="true" />
Filtern
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setOffen((wert) => !wert)}
aria-expanded={offen}
>
{offen ? 'Weniger' : 'Mehr Filter'}
{anzahlAktiv > 0 && !offen ? ` (${anzahlAktiv} aktiv)` : ''}
</Button>
{parameter.size > 0 && (
<Button type="button" variant="ghost" onClick={() => router.push('/finanzen/buchungen')}>
<RotateCcw aria-hidden="true" />
Zurücksetzen
</Button>
)}
</div>
{offen && (
<div className="grid gap-4 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={aktiv.richtung}>
<option value="">alle</option>
{BUCHUNG_RICHTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{RICHTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="kategorie" beschriftung="Kategorie">
<Select name="kategorie" defaultValue={aktiv.kategorie}>
<option value="">alle</option>
{ALLE_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="von" beschriftung="Buchungen ab">
<Input type="date" name="von" defaultValue={aktiv.von} />
</Feld>
<Feld id="bis" beschriftung="Buchungen bis">
<Input type="date" name="bis" defaultValue={aktiv.bis} />
</Feld>
<label className="flex items-center gap-3 sm:col-span-2">
<Checkbox name="nurOhneBeleg" defaultChecked={aktiv.nurOhneBeleg} />
<span className="text-sm">Nur Buchungen ohne Beleg und ohne Begründung</span>
</label>
<label className="flex items-center gap-3 sm:col-span-2">
<Checkbox name="nurEigeneAuslagen" defaultChecked={aktiv.nurEigeneAuslagen} />
<span className="text-sm">Nur eigene Auslagen des Betreuers</span>
</label>
</div>
)}
</form>
)
}
@@ -0,0 +1,343 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import { Vermischungswarnung } from '@/components/finanzen/bausteine'
import {
ALLE_KATEGORIEN,
BUCHUNG_RICHTUNGEN,
KATEGORIE_BESCHRIFTUNG,
RICHTUNG_BESCHRIFTUNG,
kategorienFuerRichtung,
standardKategorie,
} from '@/lib/finanzen/kategorien'
import { buchungSpeichern, type Formularzustand } from '@/server/actions/finanzen'
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
export type KontoAuswahl = {
id: string
bezeichnung: string
istBetreuerkonto: boolean
}
export type BelegAuswahl = {
id: string
titel: string
}
export type BuchungWerte = {
id: string
kontoId: string | null
datum: string
wertstellung: string | null
betrag: string
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
/**
* Buchung erfassen oder ändern.
*
* Drei Dinge macht dieses Formular anders als eine Haushaltsbuch-App:
*
* 1. Es warnt sichtbar, wenn eine Buchung auf einem Konto des Betreuers
* läuft und trotzdem als Vermögen der Betreuten gilt.
* 2. Es lässt eine Buchung ohne Beleg zu – verlangt dann aber eine
* Begründung, weil genau danach gefragt wird.
* 3. Es trennt laufende Vorgänge von vermögenswirksamen. Ein
* Verkaufserlös ist keine Einnahme im Sinne der Rechnungslegung.
*/
export function Buchungsformular({
betreuteId,
konten,
belege,
buchung,
standardDatum,
schnellerfassung = false,
}: {
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
/** Nach dem Speichern bleibt das Formular für die nächste Buchung offen. */
schnellerfassung?: boolean
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(buchungSpeichern, {})
const [kontoId, setKontoId] = React.useState(buchung?.kontoId ?? konten[0]?.id ?? '')
const [richtung, setRichtung] = React.useState<BuchungRichtung>(buchung?.richtung ?? 'AUSGABE')
const [kategorie, setKategorie] = React.useState<BuchungKategorie>(
buchung?.kategorie ?? 'SONSTIGE_AUSGABE',
)
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
const vorschlaege = kategorienFuerRichtung(richtung)
function richtungWechseln(neu: BuchungRichtung) {
setRichtung(neu)
// Die Kategorie folgt der Richtung, solange sie nicht dazu passt -
// sonst steht "Heimkosten" unter den Einnahmen.
if (!kategorienFuerRichtung(neu).includes(kategorie)) {
setKategorie(standardKategorie(neu))
}
}
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{buchung && <input type="hidden" name="buchungId" value={buchung.id} />}
{schnellerfassung && !buchung && <input type="hidden" name="weiterErfassen" value="on" />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="datum"
beschriftung="Buchungsdatum"
pflicht
erklaerung="Der Tag, an dem das Geld geflossen ist."
fehler={zustand.felder?.datum}
>
<Input
type="date"
name="datum"
defaultValue={buchung?.datum ?? standardDatum}
required
/>
</Feld>
<Feld
id="wertstellung"
beschriftung="Wertstellung"
erklaerung="Optional – nur, wenn sie vom Buchungsdatum abweicht."
fehler={zustand.felder?.wertstellung}
>
<Input type="date" name="wertstellung" defaultValue={buchung?.wertstellung ?? ''} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
pflicht
erklaerung="Immer positiv eintragen. Ob es Geld kostet oder bringt, sagt die Richtung."
fehler={zustand.felder?.betrag}
>
<Betragseingabe id="betrag" name="betrag" standardwert={buchung?.betrag} pflicht />
</Feld>
<Feld
id="richtung"
beschriftung="Richtung"
pflicht
erklaerung="Umbuchungen zwischen eigenen Konten zählen weder als Einnahme noch als Ausgabe."
fehler={zustand.felder?.richtung}
>
<Select
name="richtung"
value={richtung}
onChange={(ereignis) => richtungWechseln(ereignis.target.value as BuchungRichtung)}
>
{BUCHUNG_RICHTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{RICHTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="kategorie"
beschriftung="Kategorie"
pflicht
erklaerung="Nach diesen Kategorien ordnet die Rechnungslegung Einnahmen und Ausgaben."
fehler={zustand.felder?.kategorie}
>
<Select
name="kategorie"
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value as BuchungKategorie)}
>
<optgroup label={`Passend zu „${RICHTUNG_BESCHRIFTUNG[richtung]}“`}>
{vorschlaege.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
<optgroup label="Alle übrigen">
{ALLE_KATEGORIEN.filter((wert) => !vorschlaege.includes(wert)).map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
</Select>
</Feld>
<Feld
id="kontoId"
beschriftung="Konto"
erklaerung="Ohne Konto lässt sich die Buchung später keinem Kontostand zuordnen."
fehler={zustand.felder?.kontoId}
>
<Select
name="kontoId"
value={kontoId}
onChange={(ereignis) => setKontoId(ereignis.target.value)}
>
<option value="">– kein Konto –</option>
{konten.map((k) => (
<option key={k.id} value={k.id}>
{k.bezeichnung}
{k.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="gegenstelle"
beschriftung="Gegenstelle"
erklaerung="Wer hat gezahlt oder wer wurde bezahlt? Steht später im Belegverzeichnis."
fehler={zustand.felder?.gegenstelle}
>
<Input name="gegenstelle" defaultValue={buchung?.gegenstelle ?? ''} maxLength={160} />
</Feld>
<Feld
id="verwendungszweck"
beschriftung="Verwendungszweck"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.verwendungszweck}
>
<Textarea
name="verwendungszweck"
defaultValue={buchung?.verwendungszweck ?? ''}
rows={2}
maxLength={500}
/>
</Feld>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Beleg</legend>
<Feld
id="belegId"
beschriftung="Beleg aus den Dokumenten"
erklaerung="Rechnung, Quittung oder Kontoauszug, der diese Buchung belegt."
fehler={zustand.felder?.belegId}
>
<Select
name="belegId"
value={belegId}
onChange={(ereignis) => setBelegId(ereignis.target.value)}
>
<option value="">– kein Beleg hinterlegt –</option>
{belege.map((beleg) => (
<option key={beleg.id} value={beleg.id}>
{beleg.titel}
</option>
))}
</Select>
</Feld>
{belegId === '' && (
<Feld
id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?"
erklaerung="Ohne Beleg und ohne Begründung wird die Buchung in der Liste rot markiert und in der Belegquote gezählt. Verboten ist sie nicht – aber beim Gericht wird danach gefragt."
fehler={zustand.felder?.belegFehltBegruendung}
>
<Input
name="belegFehltBegruendung"
defaultValue={buchung?.belegFehltBegruendung ?? ''}
maxLength={300}
placeholder="z.B. Barkauf im Kiosk, keine Quittung erhalten"
/>
</Feld>
)}
</fieldset>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Zuordnung</legend>
<label className="flex items-start gap-3">
<Checkbox
name="betrifftBetreute"
checked={betrifftBetreute}
onChange={(ereignis) => setBetrifftBetreute(ereignis.target.checked)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">
Betrifft das Vermögen der betreuten Person
</span>
<span className="block text-sm text-muted-foreground">
Abwählen, wenn du die Ausgabe aus eigener Tasche bezahlt hast. Solche Auslagen
stehen in der Rechnungslegung getrennt und werden nach §§ 1877, 1878 BGB erstattet.
</span>
</span>
</label>
{vermischt && <Vermischungswarnung />}
<label className="flex items-start gap-3">
<Checkbox
name="vermoegenswirksam"
defaultChecked={buchung?.vermoegenswirksam}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">Vermögenswirksam</span>
<span className="block text-sm text-muted-foreground">
Für Vorgänge, die Vermögen verschieben statt laufend einzunehmen oder auszugeben –
Verkaufserlös, ausgezahlter Rückkaufswert, Erbschaft. Sie zählen in der
Rechenprobe mit, gelten aber nicht als laufende Einnahme.
</span>
</span>
</label>
</fieldset>
<div className="flex flex-wrap gap-3">
<Absendeknopf>
{buchung ? 'Änderungen speichern' : schnellerfassung ? 'Speichern und nächste' : 'Buchung erfassen'}
</Absendeknopf>
</div>
</form>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { Check, Loader2, X } from 'lucide-react'
import { Button, type ButtonProps } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ibanFormatieren, ibanPruefen } from '@/lib/finanzen/iban'
/**
* Eingabefelder, die im Finanzmodul mehrfach vorkommen.
*
* Beide sind bewusst nachsichtig: Sie korrigieren nichts hinter dem
* Ruecken des Nutzers, sondern zeigen sofort, ob die Eingabe verstanden
* wurde. Die eigentliche Pruefung passiert trotzdem noch einmal auf dem
* Server - das Formular im Browser ist nur Bequemlichkeit.
*/
/**
* Betragsfeld. inputMode="decimal" oeffnet am Handy die Zifferntastatur
* mit Komma; type="text" statt type="number", weil ein Zahlenfeld in
* deutschen Browsern beim Komma Aerger macht und Mausraeder Betraege
* verstellen koennen.
*/
export function Betragseingabe({
id,
name,
standardwert,
pflicht,
className,
...rest
}: {
id: string
name: string
standardwert?: string
pflicht?: boolean
className?: string
} & Omit<React.ComponentProps<'input'>, 'type' | 'defaultValue' | 'name' | 'id'>) {
return (
<div className="relative">
<Input
{...rest}
id={id}
name={name}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="1.234,56"
defaultValue={standardwert}
required={pflicht}
className={className}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
>
€
</span>
</div>
)
}
/**
* IBAN-Feld mit Vierergruppen und sofortiger Pruefziffernkontrolle.
* Die Rueckmeldung steht als Text daneben, nicht nur als Farbe.
*/
export function Ibaneingabe({
id,
name,
standardwert,
}: {
id: string
name: string
standardwert?: string | null
}) {
const [wert, setWert] = React.useState(ibanFormatieren(standardwert ?? ''))
const pruefung = React.useMemo(() => {
if (wert.trim() === '') return null
return ibanPruefen(wert)
}, [wert])
return (
<div className="space-y-1.5">
<Input
id={id}
name={name}
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="DE89 3704 0044 0532 0130 00"
value={wert}
onChange={(ereignis) => setWert(ibanFormatieren(ereignis.target.value))}
aria-invalid={pruefung && !pruefung.gueltig ? true : undefined}
/>
{pruefung && (
<p
className={
pruefung.gueltig
? 'flex items-center gap-1.5 text-sm text-ampel-gruen'
: 'flex items-start gap-1.5 text-sm text-destructive'
}
>
{pruefung.gueltig ? (
<>
<Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
Prüfziffer stimmt ({pruefung.land}).
</>
) : (
<>
<X className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{pruefung.meldung}
</>
)}
</p>
)}
</div>
)
}
/** Schaltfläche, die während des Absendens sperrt und das sagt. */
export function Absendeknopf({
children,
wartetext = 'Wird gespeichert …',
...rest
}: ButtonProps & { wartetext?: string }) {
const status = useFormStatus()
return (
<Button {...rest} type="submit" disabled={status.pending || rest.disabled}>
{status.pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
{status.pending ? wartetext : children}
</Button>
)
}
+136
View File
@@ -0,0 +1,136 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import { importAusfuehren, type Formularzustand } from '@/server/actions/finanzen'
/**
* Kontoumsätze aus einer Datei einlesen.
*
* Abtippen ist die Stelle, an der in der Rechnungslegung die Fehler
* entstehen. Deshalb dieser Weg – und deshalb der Dublettenschutz:
* Dieselbe Datei zweimal einzulesen legt nichts doppelt an.
*/
export function Importformular({
betreuteId,
konten,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string; istBetreuerkonto: boolean }[]
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(importAusfuehren, {})
return (
<form action={absenden} className="space-y-5">
<input type="hidden" name="betreuteId" value={betreuteId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>{zustand.erfolg}</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-disc space-y-1 pl-5">
{(zustand.meldung ?? []).map((zeile, i) => (
<li key={i}>{zeile}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{!zustand.erfolg && zustand.meldung && zustand.meldung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Diese Zeilen waren nicht lesbar</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-disc space-y-1 pl-5">
{zustand.meldung.map((zeile, i) => (
<li key={i}>{zeile}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Feld
id="kontoId"
beschriftung="Konto"
pflicht
erklaerung="Zu welchem Konto gehören die Umsätze in der Datei?"
fehler={zustand.felder?.kontoId}
>
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
{konto.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
</option>
))}
</Select>
</Feld>
<Feld
id="datei"
beschriftung="Datei"
pflicht
erklaerung="CSV aus dem Online-Banking oder der elektronische Kontoauszug als CAMT.053-XML. Deutsche Zahlen (1.234,56), Semikolon oder Komma als Trennzeichen und Windows-Umlaute werden erkannt. Höchstens 8 MB."
fehler={zustand.felder?.datei}
>
<Input
type="file"
name="datei"
accept=".csv,.txt,.xml,text/csv,text/plain,application/xml,text/xml"
required
className="py-2"
/>
</Feld>
<label className="flex items-start gap-3">
<Checkbox name="betrifftBetreute" defaultChecked className="mt-0.5" />
<span className="space-y-1">
<span className="block font-medium">
Die Umsätze betreffen das Vermögen der betreuten Person
</span>
<span className="block text-sm text-muted-foreground">
Abwählen, wenn du ein eigenes Konto einliest, über das nur deine Auslagen laufen.
</span>
</span>
</label>
<label className="flex items-start gap-3">
<Checkbox name="dublettenUebernehmen" className="mt-0.5" />
<span className="space-y-1">
<span className="block font-medium">Auch bereits vorhandene Umsätze anlegen</span>
<span className="block text-sm text-muted-foreground">
Normalerweise nicht anhaken. Nur sinnvoll, wenn du weißt, dass es tatsächlich zwei
gleiche Buchungen gibt und der Dublettenschutz zu streng war.
</span>
</span>
</label>
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Kategorien werden nicht geraten</AlertTitle>
<AlertDescription>
Importierte Buchungen landen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“.
Eine automatisch geratene Kategorie wäre in der Rechnungslegung schlimmer als gar keine
– ordne sie danach in der Buchungsliste zu.
</AlertDescription>
</Alert>
<Absendeknopf wartetext="Datei wird gelesen …">Datei einlesen</Absendeknopf>
</form>
)
}
+183
View File
@@ -0,0 +1,183 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf, Ibaneingabe } from '@/components/finanzen/eingaben'
import {
KONTO_ARTEN,
KONTO_ART_BESCHRIFTUNG,
KONTO_ART_ERKLAERUNG,
KONTO_ARTEN_OHNE_IBAN,
} from '@/lib/finanzen/kategorien'
import { kontoSpeichern, type Formularzustand } from '@/server/actions/finanzen'
import type { KontoArt } from '@prisma/client'
/**
* Konto anlegen oder ändern.
*
* Die IBAN wird verschlüsselt gespeichert und zusätzlich als Blindindex
* abgelegt. Deshalb kann die Anwendung „gibt es dieses Konto schon?“
* beantworten, ohne die IBAN im Klartext in der Datenbank zu halten.
*/
export function Kontoformular({
betreuteId,
konto,
}: {
betreuteId: string
konto?: {
id: string
bezeichnung: string
bank: string | null
art: KontoArt
iban: string | null
bic: string | null
istBetreuerkonto: boolean
eroeffnetAm: string | null
geschlossenAm: string | null
}
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(kontoSpeichern, {})
const [art, setArt] = React.useState<KontoArt>(konto?.art ?? 'GIRO')
const [istBetreuerkonto, setIstBetreuerkonto] = React.useState(konto?.istBetreuerkonto ?? false)
const ibanNoetig = !KONTO_ARTEN_OHNE_IBAN.includes(art)
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{konto && <input type="hidden" name="kontoId" value={konto.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
pflicht
erklaerung="Wie du das Konto im Alltag nennst, z.B. „Girokonto Sparkasse“."
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={konto?.bezeichnung} required maxLength={120} />
</Feld>
<Feld
id="bank"
beschriftung="Bank oder Einrichtung"
erklaerung="Bei einem Verwahrgeldkonto der Name des Heims."
fehler={zustand.felder?.bank}
>
<Input name="bank" defaultValue={konto?.bank ?? ''} maxLength={120} />
</Feld>
<Feld
id="art"
beschriftung="Kontoart"
pflicht
erklaerung={KONTO_ART_ERKLAERUNG[art]}
fehler={zustand.felder?.art}
>
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as KontoArt)}
>
{KONTO_ARTEN.map((wert) => (
<option key={wert} value={wert}>
{KONTO_ART_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="bic"
beschriftung="BIC"
erklaerung="Optional. Acht oder elf Stellen."
fehler={zustand.felder?.bic}
>
<Input name="bic" defaultValue={konto?.bic ?? ''} maxLength={11} autoComplete="off" />
</Feld>
</div>
<Feld
id="iban"
beschriftung="IBAN"
erklaerung={
ibanNoetig
? 'Wird verschlüsselt gespeichert. Die Prüfziffer wird sofort nachgerechnet.'
: 'Für Barkasse und Verwahrgeld im Heim gibt es in der Regel keine IBAN – das Feld darf leer bleiben.'
}
fehler={zustand.felder?.iban}
>
<Ibaneingabe id="iban" name="iban" standardwert={konto?.iban ?? ''} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="eroeffnetAm"
beschriftung="Eröffnet am"
erklaerung="Optional. Hilft, alte Kontoauszüge einzuordnen."
fehler={zustand.felder?.eroeffnetAm}
>
<Input type="date" name="eroeffnetAm" defaultValue={konto?.eroeffnetAm ?? ''} />
</Feld>
<Feld
id="geschlossenAm"
beschriftung="Geschlossen am"
erklaerung="Ein geschlossenes Konto bleibt stehen – die Rechnungslegung braucht es weiter."
fehler={zustand.felder?.geschlossenAm}
>
<Input type="date" name="geschlossenAm" defaultValue={konto?.geschlossenAm ?? ''} />
</Feld>
</div>
<div className="space-y-2 rounded-md border p-4">
<label className="flex items-start gap-3">
<Checkbox
name="istBetreuerkonto"
defaultChecked={konto?.istBetreuerkonto}
onChange={(ereignis) => setIstBetreuerkonto(ereignis.target.checked)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">Das ist ein Konto des Betreuers</span>
<span className="block text-sm text-muted-foreground">
Also dein eigenes Konto, nicht das der betreuten Person. Buchungen darauf gehören
nicht in das Vermögen der Betreuten und tauchen in der Rechnungslegung nur als
eigene Auslage auf.
</span>
</span>
</label>
{istBetreuerkonto && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Zwei Vermögen, zwei Konten</AlertTitle>
<AlertDescription>
Von diesem Konto darf kein Geld der betreuten Person verwaltet werden. Wenn du hier
Buchungen erfasst, die das Vermögen der Betreuten betreffen, warnt dich die
Anwendung – das ist der Fehler, der in der Rechnungslegung Ärger macht.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex gap-3">
<Absendeknopf>{konto ? 'Änderungen speichern' : 'Konto anlegen'}</Absendeknopf>
</div>
</form>
)
}
@@ -0,0 +1,283 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import {
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
VERMOEGENS_ARTEN,
VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien'
import {
vermoegenspostenSpeichern,
wertstandAnlegen,
type Formularzustand,
} from '@/server/actions/finanzen'
import type { SozialhilfeBewertung, VermoegensArt } from '@prisma/client'
const BEWERTUNGEN: SozialhilfeBewertung[] = [
'UNGEPRUEFT',
'VERWERTBAR',
'GESCHUETZT',
'STRITTIG',
]
/**
* Vermögensposten anlegen oder ändern.
*
* Der Posten selbst trägt keinen Betrag – der steckt in den
* Wertständen. Ein Posten ist die Sache („Hausgrundstück
* Bayreuther Str. 4“), ein Wertstand ist eine Aussage über ihren Wert an
* einem bestimmten Tag mit einer bestimmten Quelle.
*/
export function Postenformular({
betreuteId,
konten,
posten,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string }[]
posten?: {
id: string
bezeichnung: string
art: VermoegensArt
kontoId: string | null
bewertungSozialhilfe: SozialhilfeBewertung
bewertungBegruendung: string | null
notizen: string | null
}
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
vermoegenspostenSpeichern,
{},
)
const [art, setArt] = React.useState<VermoegensArt>(posten?.art ?? 'VERSICHERUNG')
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{posten && <input type="hidden" name="postenId" value={posten.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
pflicht
erklaerung="So genau, dass der Posten im Verzeichnis eindeutig ist."
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={posten?.bezeichnung} required maxLength={160} />
</Feld>
<Feld
id="art"
beschriftung="Art"
pflicht
erklaerung={
art === 'VERBINDLICHKEIT'
? 'Verbindlichkeiten werden mit positivem Betrag geführt – dass sie das Vermögen mindern, ergibt sich aus der Art.'
: 'Bestimmt, unter welcher Überschrift der Posten im Vermögensverzeichnis steht.'
}
fehler={zustand.felder?.art}
>
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as VermoegensArt)}
>
{VERMOEGENS_ARTEN.map((wert) => (
<option key={wert} value={wert}>
{VERMOEGENS_ART_EINZAHL[wert]}
</option>
))}
</Select>
</Feld>
</div>
{art === 'KONTO' && (
<>
<Feld
id="kontoId"
beschriftung="Zugehöriges Konto"
erklaerung="Verknüpft den Posten mit einem erfassten Konto."
fehler={zustand.felder?.kontoId}
>
<Select name="kontoId" defaultValue={posten?.kontoId ?? ''}>
<option value="">– keines –</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Konten stehen schon im Verzeichnis</AlertTitle>
<AlertDescription>
Konten kommen mit ihrem Saldostand ins Vermögensverzeichnis. Ein zusätzlicher Posten
der Art „Konto“ wird dort deshalb <strong>nicht</strong> mitgezählt – sonst stünde
dasselbe Guthaben zweimal in der Summe. Für Depots, deren Wert du gesondert
belegen willst, ist der Posten trotzdem sinnvoll.
</AlertDescription>
</Alert>
</>
)}
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung im Sozialhilfeverfahren"
erklaerung="Deine eigene Einschätzung und Dokumentation gegenüber dem Bezirk – keine Rechtsauskunft."
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
defaultValue={posten?.bewertungSozialhilfe ?? 'UNGEPRUEFT'}
>
{BEWERTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung der Einordnung"
erklaerung="Warum hältst du den Posten für geschützt oder verwertbar? Verweis auf § 90 SGB XII, Schonvermögen, Bestattungsvorsorge, selbst bewohntes Hausgrundstück. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.bewertungBegruendung}
>
<Textarea
name="bewertungBegruendung"
defaultValue={posten?.bewertungBegruendung ?? ''}
rows={3}
maxLength={1000}
/>
</Feld>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={posten?.notizen ?? ''} rows={3} maxLength={1000} />
</Feld>
<Absendeknopf>{posten ? 'Änderungen speichern' : 'Posten anlegen'}</Absendeknopf>
</form>
)
}
/**
* Wertstand erfassen.
*
* Die Wertquelle ist Pflichtfeld – und zwar nicht aus Formalismus: Eine
* Zahl im Vermögensverzeichnis, bei der sich nach zwei Jahren nicht mehr
* klären lässt, woher sie stammt, ist wertlos. Steht dagegen
* „Rückkaufswertmitteilung vom 12.03.2026“ daneben, ist die Rückfrage
* beantwortet, bevor sie gestellt wird.
*/
export function Wertstandformular({
betreuteId,
vermoegenspostenId,
dokumente,
standardStichtag,
}: {
betreuteId: string
vermoegenspostenId: string
dokumente: { id: string; titel: string }[]
standardStichtag?: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(wertstandAnlegen, {})
return (
<form action={absenden} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vermoegenspostenId" value={vermoegenspostenId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="stichtag"
beschriftung="Stichtag"
pflicht
erklaerung="Auf welchen Tag bezieht sich der Wert?"
fehler={zustand.felder?.stichtag}
>
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
</Feld>
<Feld id="wert" beschriftung="Wert" pflicht fehler={zustand.felder?.wert}>
<Betragseingabe id="wert" name="wert" pflicht />
</Feld>
</div>
<Feld
id="wertquelle"
beschriftung="Wertquelle"
pflicht
erklaerung="Woher stammt der Wert? „Rückkaufswertmitteilung vom 12.03.2026“, „Maklergutachten“, „Bodenrichtwert“, „eigene Schätzung“."
fehler={zustand.felder?.wertquelle}
>
<Input
name="wertquelle"
required
maxLength={200}
placeholder="Rückkaufswertmitteilung vom 12.03.2026"
/>
</Feld>
<Feld
id="nachweisDokumentId"
beschriftung="Nachweisdokument"
erklaerung="Optional, aber hilfreich: das Schreiben, aus dem der Wert stammt."
fehler={zustand.felder?.nachweisDokumentId}
>
<Select name="nachweisDokumentId" defaultValue="">
<option value="">– kein Nachweis hinterlegt –</option>
{dokumente.map((dokument) => (
<option key={dokument.id} value={dokument.id}>
{dokument.titel}
</option>
))}
</Select>
</Feld>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Input name="bemerkung" maxLength={500} />
</Feld>
<Absendeknopf>Wert festhalten</Absendeknopf>
</form>
)
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import { saldostandAnlegen, type Formularzustand } from '@/server/actions/finanzen'
/**
* Kontostand zu einem Stichtag festhalten.
*
* Ein Saldostand wird nie überschrieben. Jeder Eintrag bleibt stehen,
* damit sich eine einmal abgegebene Vermögensübersicht Jahre später
* exakt wieder herstellen lässt – auch dann, wenn inzwischen ein
* Kontoauszug korrigiert wurde.
*/
export function Saldoformular({
betreuteId,
konten,
kontoId,
standardStichtag,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string }[]
kontoId?: string
standardStichtag?: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(saldostandAnlegen, {})
return (
<form action={absenden} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
{kontoId ? (
<input type="hidden" name="kontoId" value={kontoId} />
) : (
<Feld id="kontoId" beschriftung="Konto" pflicht fehler={zustand.felder?.kontoId}>
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="stichtag"
beschriftung="Stichtag"
pflicht
erklaerung="Der Stand gilt als Bestand am Ende dieses Tages."
fehler={zustand.felder?.stichtag}
>
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
</Feld>
<Feld
id="betrag"
beschriftung="Kontostand"
pflicht
erklaerung="Ein überzogenes Konto wird mit Minus eingetragen."
fehler={zustand.felder?.betrag}
>
<Betragseingabe id="betrag" name="betrag" pflicht />
</Feld>
</div>
<Feld
id="quelle"
beschriftung="Woher stammt der Stand?"
erklaerung="z.B. „Kontoauszug Nr. 3“ oder „Online-Banking, abgerufen am 15.03.“. Das beantwortet die Rückfrage des Rechtspflegers vorab."
fehler={zustand.felder?.quelle}
>
<Input name="quelle" maxLength={160} placeholder="Kontoauszug Nr. 3" />
</Feld>
<Absendeknopf>Kontostand festhalten</Absendeknopf>
</form>
)
}
+175
View File
@@ -0,0 +1,175 @@
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatCent, formatDatum } from '@/lib/utils'
import { Betrag } from '@/components/finanzen/bausteine'
export type Verlaufszeile = {
id: string
/** ISO-Datum, damit der Wert auch an Client-Komponenten weitergereicht werden kann. */
stichtag: string
cent: number
wertquelle: string
nachweisTitel: string | null
bemerkung: string | null
veraenderungCent: number | null
/** Aktion am Zeilenende, z.B. ein Löschknopf. */
aktion?: React.ReactNode
}
/**
* Wertverlauf eines Vermögenspostens: Tabelle plus eine kleine Kurve.
*
* Die Kurve ist bewusst handgezeichnetes SVG und kein Diagrammpaket.
* Sie soll eine einzige Frage beantworten – „geht es rauf oder runter?“ –
* und dabei nichts nachladen, sauber ausdrucken und in beiden
* Farbschemata lesbar bleiben. Die genauen Zahlen stehen darunter in
* der Tabelle; die Kurve ersetzt sie nicht.
*/
export function Wertverlauf({ zeilen }: { zeilen: Verlaufszeile[] }) {
return (
<div className="space-y-4">
<Verlaufskurve zeilen={zeilen} />
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead>Wert</TableHead>
<TableHead>Veränderung</TableHead>
<TableHead>Wertquelle</TableHead>
<TableHead>Nachweis</TableHead>
{zeilen.some((z) => z.aktion) && <TableHead className="text-right">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{[...zeilen]
.sort((a, b) => b.stichtag.localeCompare(a.stichtag))
.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell className="whitespace-nowrap">
{formatDatum(new Date(zeile.stichtag))}
</TableCell>
<TableCell>
<Betrag cent={zeile.cent} fett />
</TableCell>
<TableCell>
{zeile.veraenderungCent === null ? (
<span className="text-muted-foreground">erster Wert</span>
) : (
<span
className={
zeile.veraenderungCent < 0
? 'tabular-nums text-ampel-rot'
: zeile.veraenderungCent > 0
? 'tabular-nums text-ampel-gruen'
: 'tabular-nums text-muted-foreground'
}
>
{zeile.veraenderungCent > 0 ? '+' : ''}
{formatCent(zeile.veraenderungCent)}
</span>
)}
</TableCell>
<TableCell>
<span className="block">{zeile.wertquelle}</span>
{zeile.bemerkung && (
<span className="block text-sm text-muted-foreground">{zeile.bemerkung}</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{zeile.nachweisTitel ?? 'kein Dokument hinterlegt'}
</TableCell>
{zeilen.some((z) => z.aktion) && (
<TableCell className="text-right">{zeile.aktion}</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
const BREITE = 640
const HOEHE = 120
const RAND = 8
/** Normiert die Werte auf das Zeichenfeld und zeichnet eine Polylinie. */
export function Verlaufskurve({ zeilen }: { zeilen: Verlaufszeile[] }) {
if (zeilen.length < 2) {
return (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Sobald ein zweiter Wertstand erfasst ist, siehst du hier den Verlauf.
</p>
)
}
const sortiert = [...zeilen].sort((a, b) => a.stichtag.localeCompare(b.stichtag))
const zeiten = sortiert.map((z) => new Date(z.stichtag).getTime())
const werte = sortiert.map((z) => z.cent)
const tMin = Math.min(...zeiten)
const tMax = Math.max(...zeiten)
const wMin = Math.min(...werte)
const wMax = Math.max(...werte)
const tSpanne = tMax - tMin || 1
const wSpanne = wMax - wMin || 1
const punkte = sortiert.map((zeile, i) => ({
x: RAND + ((zeiten[i]! - tMin) / tSpanne) * (BREITE - 2 * RAND),
y: HOEHE - RAND - ((werte[i]! - wMin) / wSpanne) * (HOEHE - 2 * RAND),
zeile,
}))
const linie = punkte.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
const flaeche = `${RAND},${HOEHE - RAND} ${linie} ${(BREITE - RAND).toFixed(1)},${HOEHE - RAND}`
const beschreibung =
`Wertverlauf von ${formatCent(werte[0]!)} am ${formatDatum(new Date(sortiert[0]!.stichtag))} ` +
`bis ${formatCent(werte[werte.length - 1]!)} am ` +
`${formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))}.`
return (
<figure className="space-y-1">
<div className="overflow-x-auto rounded-md border bg-card p-2">
<svg
viewBox={`0 0 ${BREITE} ${HOEHE}`}
className="h-28 w-full min-w-[20rem]"
role="img"
aria-label={beschreibung}
preserveAspectRatio="none"
>
<polygon points={flaeche} className="fill-primary/10" />
<polyline
points={linie}
fill="none"
className="stroke-primary"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{punkte.map((punkt) => (
<circle
key={punkt.zeile.id}
cx={punkt.x}
cy={punkt.y}
r={3}
className="fill-primary"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<figcaption className="flex flex-wrap justify-between gap-2 text-sm text-muted-foreground">
<span>
{formatDatum(new Date(sortiert[0]!.stichtag))} · {formatCent(wMin)} (niedrigster Wert)
</span>
<span>
{formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))} · {formatCent(wMax)}{' '}
(höchster Wert)
</span>
</figcaption>
</figure>
)
}
+57
View File
@@ -0,0 +1,57 @@
import Link from 'next/link'
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
import { cn } from '@/lib/utils'
import { hinweiseFuerKontext } from '@/lib/hinweise/laden'
/**
* Zeigt die Hinweiskarten, die zum Kontext einer Seite passen.
*
* Die Karten kommen aus daten/hinweise.json und sind kein Beiwerk: Sie
* stehen an der Stelle, an der ein Fehler teuer wuerde - die Warnung vor
* der Genehmigungspflicht steht neben der Wohnungsaufloesung, nicht in
* einem Handbuch. Zu jeder Karte gehoert der stehende Rechtshinweis: Das
* hier ist eine Gedaechtnisstuetze, keine Rechtsberatung.
*
* Kontexte sind Modulnamen ("IMMOBILIE"), "VORGANG:<Kategorie>",
* "VORLAGE:<schluessel>" oder "FRIST:<systemSchluessel>". Ein Ausloeser
* ohne Jahreszahl passt auch auf die Frist mit Jahreszahl, damit
* "FRIST:jahresbericht" nicht jaehrlich nachgepflegt werden muss.
*
* Serverkomponente - sie liest die Wissensbasis von der Platte.
*/
export function HinweisKarten({
kontext,
grenze = 3,
ueberschrift = 'Darauf solltest du achten',
className,
}: {
kontext: readonly string[]
/** Wie viele Karten hoechstens? Mehr als drei liest niemand. */
grenze?: number
ueberschrift?: string
className?: string
}) {
const karten = hinweiseFuerKontext(kontext, grenze)
if (karten.length === 0) return null
return (
<section className={cn('space-y-3', className)} aria-label={ueberschrift}>
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-base font-semibold">{ueberschrift}</h2>
<Link
href="/hinweise"
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Alle Hinweise
</Link>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{karten.map((karte) => (
<HinweisKarte key={karte.schluessel} karte={karte} klein />
))}
</div>
</section>
)
}
export { HinweisKarte }
+44
View File
@@ -0,0 +1,44 @@
import Link from 'next/link'
import { ExternalLink, Lightbulb } from 'lucide-react'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { Hinweiskarte } from '@/lib/hinweise/laden'
/**
* Die Darstellung einer einzelnen Hinweiskarte.
*
* Bewusst in einer eigenen Datei und ohne Zugriff auf die Wissensbasis:
* Diese Komponente wird auch aus der Suche im Browser heraus benutzt, und
* das Laden der JSON-Datei geht ueber "node:fs" - das darf nie im
* Browserbuendel landen. Deshalb hier nur der Typ, nie der Lader.
*/
export function HinweisKarte({ karte, klein = false }: { karte: Hinweiskarte; klein?: boolean }) {
return (
<Card className={cn(klein && 'shadow-none')}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Lightbulb className="mt-0.5 size-5 shrink-0 text-ampel-gelb" aria-hidden="true" />
<div className="min-w-0 space-y-1">
<CardTitle className={cn('leading-snug', klein ? 'text-base' : 'text-lg')}>
{karte.titel}
</CardTitle>
<Badge variant="outline">{karte.rechtsgrundlage}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-relaxed">{karte.text}</p>
<Link
href={karte.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{karte.rechtsgrundlage} im Gesetzestext nachlesen
<ExternalLink className="size-4" aria-hidden="true" />
</Link>
<Rechtshinweis rechtsgrundlage={karte.rechtsgrundlage} kurz />
</CardContent>
</Card>
)
}
+82
View File
@@ -0,0 +1,82 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Leerzustand } from '@/components/seiten-kopf'
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
import type { Hinweiskarte } from '@/lib/hinweise/laden'
/**
* Durchsuchbare Liste aller Hinweiskarten.
*
* Die Karten kommen fertig geladen von der Serverseite - gesucht wird im
* Browser, weil es nur ein paar Dutzend sind und jede Runde zum Server
* sich beim Tippen wie Warten anfuehlen wuerde. Die Suchlogik steht hier
* noch einmal, statt aus lib/hinweise/laden.ts importiert zu werden: Jenes
* Modul liest die Datei mit "node:fs" und gehoert nicht ins Browserbuendel.
*/
function suchen(karten: Hinweiskarte[], suche: string): Hinweiskarte[] {
const sortiert = [...karten].sort(
(a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'),
)
const begriff = suche.trim().toLowerCase()
if (begriff === '') return sortiert
return sortiert.filter((karte) =>
[karte.titel, karte.text, karte.rechtsgrundlage, karte.schluessel, ...karte.kontext]
.join(' ')
.toLowerCase()
.includes(begriff),
)
}
export function HinweisSuche({ karten }: { karten: Hinweiskarte[] }) {
const [suche, setSuche] = React.useState('')
const treffer = React.useMemo(() => suchen(karten, suche), [karten, suche])
return (
<div className="space-y-5">
<Feld
id="hinweis-suche"
beschriftung="Suchen"
erklaerung="Sucht in Überschrift, Text, Rechtsgrundlage und Auslöser – zum Beispiel „Genehmigung“, „1878“ oder „Heim“."
>
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 opacity-60"
aria-hidden="true"
/>
<Input
type="search"
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Stichwort oder Paragraph"
className="pl-9"
autoComplete="off"
/>
</div>
</Feld>
<p className="text-sm text-muted-foreground" aria-live="polite">
{treffer.length === karten.length
? `${karten.length} Hinweise`
: `${treffer.length} von ${karten.length} Hinweisen`}
</p>
{treffer.length === 0 ? (
<Leerzustand
titel="Dazu ist nichts hinterlegt"
beschreibung="Die Hinweise stehen in daten/hinweise.json. Fehlt dir etwas, das du immer wieder nachschlägst, trag es dort nach – die Datei wird beim nächsten Start eingelesen."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
{treffer.map((karte) => (
<HinweisKarte key={karte.schluessel} karte={karte} />
))}
</div>
)}
</div>
)
}
+254
View File
@@ -0,0 +1,254 @@
'use client'
import { useActionState, useState } from 'react'
import { BookOpen, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BELASTUNG_ARTEN,
BELASTUNG_ART_ABTEILUNG,
GRUNDBUCH_ABTEILUNGEN,
GRUNDBUCH_ABTEILUNG_ERKLAERUNG,
} from '@/lib/immobilie/konstanten'
import { belastungLoeschen, belastungSpeichern } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type BelastungZeile = {
id: string
art: string
abteilung: string | null
laufendeNr: string | null
glaeubiger: string | null
betragCent: number | null
bemerkung: string | null
geloeschtImGrundbuchAm: string | null
}
export function Belastungen({
betreuteId,
immobilieId,
belastungen,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
belastungen: BelastungZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(belastungSpeichern, {})
const [formularOffen, setFormularOffen] = useState(false)
const [art, setArt] = useState<string>('Grundschuld')
const vorschlag = BELASTUNG_ART_ABTEILUNG[art as keyof typeof BELASTUNG_ART_ABTEILUNG] ?? 'II'
const aktive = belastungen.filter((b) => !b.geloeschtImGrundbuchAm)
const summe = aktive
.filter((b) => b.abteilung === 'III')
.reduce((wert, b) => wert + (b.betragCent ?? 0), 0)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="size-5" aria-hidden="true" />
Belastungen im Grundbuch
</CardTitle>
<CardDescription>
Was in Abteilung II und III eingetragen ist. Beides mindert den Wert — auf ganz
unterschiedliche Weise.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
{GRUNDBUCH_ABTEILUNGEN.map((abteilung) => (
<div key={abteilung} className="rounded-md border bg-muted/40 p-3">
<p className="text-sm text-muted-foreground">
{GRUNDBUCH_ABTEILUNG_ERKLAERUNG[abteilung]}
</p>
</div>
))}
</div>
{belastungen.length === 0 ? (
<Leerzustand
titel="Noch keine Belastungen erfasst"
beschreibung="Nimm den Grundbuchauszug zur Hand und trage ein, was in Abteilung II und III steht. Ohne diese Zahlen lässt sich der Wert des Grundstücks nicht beziffern."
icon={BookOpen}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setFormularOffen(true)}>
<Plus aria-hidden="true" />
Belastung eintragen
</Button>
)
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Abt.</TableHead>
<TableHead>lfd. Nr.</TableHead>
<TableHead>Art</TableHead>
<TableHead>Gläubiger / Berechtigter</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Status</TableHead>
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{belastungen.map((belastung) => (
<TableRow key={belastung.id}>
<TableCell className="font-medium">{belastung.abteilung ?? '–'}</TableCell>
<TableCell>{belastung.laufendeNr ?? '–'}</TableCell>
<TableCell>{belastung.art}</TableCell>
<TableCell>{belastung.glaeubiger ?? '–'}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(belastung.betragCent)}
</TableCell>
<TableCell>
{belastung.geloeschtImGrundbuchAm ? (
<Badge variant="gruen">
gelöscht am {formatDatum(belastung.geloeschtImGrundbuchAm)}
</Badge>
) : (
<Badge variant="outline">eingetragen</Badge>
)}
</TableCell>
{darfSchreiben && (
<TableCell>
<form
action={async () => {
await belastungLoeschen(betreuteId, immobilieId, belastung.id)
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
aria-label={`${belastung.art} entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{summe > 0 && (
<p className="text-sm text-muted-foreground">
Nennbetrag der eingetragenen Grundpfandrechte (Abteilung III):{' '}
<strong className="text-foreground">{formatCent(summe)}</strong>. Das ist die
Höchstsumme aus dem Grundbuch, nicht die Restschuld — frag bei der Bank nach
einer aktuellen Ablösesumme.
</p>
)}
</>
)}
{darfSchreiben && (formularOffen || belastungen.length > 0) && (
<div className="rounded-md border p-4">
{!formularOffen ? (
<Button type="button" variant="outline" onClick={() => setFormularOffen(true)}>
<Plus aria-hidden="true" />
Weitere Belastung eintragen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="belastung-art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" value={art} onChange={(e) => setArt(e.target.value)}>
{BELASTUNG_ARTEN.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</Select>
</Feld>
<Feld
id="belastung-abteilung"
beschriftung="Grundbuchabteilung"
erklaerung={`Bei „${art}“ üblicherweise Abteilung ${vorschlag}.`}
fehler={zustand.felder?.abteilung}
>
<Select name="abteilung" defaultValue={vorschlag} key={vorschlag}>
{GRUNDBUCH_ABTEILUNGEN.map((a) => (
<option key={a} value={a}>
Abteilung {a}
</option>
))}
</Select>
</Feld>
<Feld
id="belastung-laufendeNr"
beschriftung="Laufende Nummer"
erklaerung="Die Nummer der Eintragung in der jeweiligen Abteilung."
fehler={zustand.felder?.laufendeNr}
>
<Input name="laufendeNr" maxLength={20} />
</Feld>
<Feld
id="belastung-glaeubiger"
beschriftung="Gläubiger / Berechtigter"
fehler={zustand.felder?.glaeubiger}
>
<Input name="glaeubiger" maxLength={150} />
</Feld>
<Feld
id="belastung-betrag"
beschriftung="Betrag"
erklaerung="Der eingetragene Nennbetrag, z. B. 50.000,00"
fehler={zustand.felder?.betrag}
>
<Input name="betrag" inputMode="decimal" placeholder="50.000,00" />
</Feld>
<Feld
id="belastung-geloescht"
beschriftung="Im Grundbuch gelöscht am"
fehler={zustand.felder?.geloeschtImGrundbuchAm}
>
<Input name="geloeschtImGrundbuchAm" type="date" />
</Feld>
</div>
<Feld id="belastung-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" rows={2} maxLength={1000} />
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Belastung speichern</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setFormularOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
)
}
+262
View File
@@ -0,0 +1,262 @@
'use client'
import { useActionState, useState } from 'react'
import { CalendarClock, ClipboardList, Plus, Repeat, Trash2, TriangleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
checklisteUebernehmen,
checkpunktErledigen,
checkpunktLoeschen,
checkpunktSpeichern,
} from '@/server/actions/immobilie'
import { formatDatum, relativeTage } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type CheckpunktZeile = {
id: string
titel: string
beschreibung: string | null
erledigt: boolean
erledigtAm: string | null
faelligAm: string | null
wiederholungRrule: string | null
tageBisFaellig: number | null
}
function ampel(punkt: CheckpunktZeile): { variante: 'rot' | 'orange' | 'gelb' | 'gruen'; text: string } {
if (punkt.erledigt) return { variante: 'gruen', text: 'erledigt' }
if (punkt.tageBisFaellig === null) return { variante: 'gelb', text: 'ohne Termin' }
if (punkt.tageBisFaellig < 0) {
return { variante: 'rot', text: `überfällig seit ${Math.abs(punkt.tageBisFaellig)} Tagen` }
}
if (punkt.tageBisFaellig <= 7) return { variante: 'orange', text: relativeTage(punkt.tageBisFaellig) }
if (punkt.tageBisFaellig <= 31) return { variante: 'gelb', text: relativeTage(punkt.tageBisFaellig) }
return { variante: 'gruen', text: relativeTage(punkt.tageBisFaellig) }
}
export function Checkliste({
betreuteId,
immobilieId,
punkte,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
punkte: CheckpunktZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checkpunktSpeichern, {})
const [uebernahme, uebernahmeAktion] = useActionState<Formularzustand, FormData>(
checklisteUebernehmen,
{},
)
const [offen, setOffen] = useState(false)
const erledigt = punkte.filter((p) => p.erledigt).length
const ueberfaellig = punkte.filter(
(p) => !p.erledigt && p.tageBisFaellig !== null && p.tageBisFaellig < 0,
)
if (punkte.length === 0) {
return (
<div className="space-y-4">
<Zustandsmeldung zustand={uebernahme} />
<Leerzustand
titel="Noch keine Checkliste angelegt"
beschreibung="Die vorbereitete Liste enthält zehn Punkte, die bei einem leerstehenden Haus regelmäßig teuer werden — vom Gebäudeversicherer bis zum Frostschutz. Jeder Punkt lässt sich danach ändern, verschieben oder löschen."
icon={ClipboardList}
aktion={
darfSchreiben && (
<form action={uebernahmeAktion}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Absendeknopf>Checkliste übernehmen</Absendeknopf>
</form>
)
}
/>
</div>
)
}
return (
<div className="space-y-5">
<Zustandsmeldung zustand={uebernahme} />
<Card>
<CardContent className="space-y-4 pt-5">
<Fortschritt
wert={erledigt}
gesamt={punkte.length}
beschriftung="Punkte abgehakt"
/>
{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 Punkt ist überfällig.'
: `${ueberfaellig.length} Punkte sind überfällig.`}{' '}
Bei Leerstand ist das kein Schönheitsfehler — der Versicherer fragt im
Schadensfall danach.
</p>
)}
</CardContent>
</Card>
<ul className="space-y-3">
{punkte.map((punkt) => {
const lage = ampel(punkt)
return (
<li key={punkt.id}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
{darfSchreiben && (
<form
action={async () => {
await checkpunktErledigen(
betreuteId,
immobilieId,
punkt.id,
!punkt.erledigt,
)
}}
>
<button
type="submit"
className="flex size-11 items-center justify-center rounded-md hover:bg-accent"
aria-label={
punkt.erledigt
? `„${punkt.titel}“ wieder öffnen`
: `„${punkt.titel}“ abhaken`
}
>
<Checkbox checked={punkt.erledigt} readOnly tabIndex={-1} />
</button>
</form>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<CardTitle className="text-base">{punkt.titel}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={lage.variante}>{lage.text}</Badge>
{punkt.faelligAm && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" aria-hidden="true" />
fällig {formatDatum(punkt.faelligAm)}
</span>
)}
{punkt.wiederholungRrule && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Repeat className="size-3.5" aria-hidden="true" />
wiederkehrend
</span>
)}
{punkt.erledigtAm && (
<span className="text-sm text-muted-foreground">
zuletzt erledigt {formatDatum(punkt.erledigtAm)}
</span>
)}
</div>
</div>
{darfSchreiben && (
<form
action={async () => {
await checkpunktLoeschen(betreuteId, immobilieId, punkt.id)
}}
>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`„${punkt.titel}“ löschen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
)}
</div>
</CardHeader>
{punkt.beschreibung && (
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{punkt.beschreibung}</p>
</CardContent>
)}
</Card>
</li>
)
})}
</ul>
{darfSchreiben && (
<div className="rounded-md border p-4">
{!offen ? (
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Eigenen Punkt hinzufügen
</Button>
<form action={uebernahmeAktion}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Absendeknopf variant="ghost">Fehlende Standardpunkte nachholen</Absendeknopf>
</form>
</div>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<Feld id="punkt-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" required maxLength={200} />
</Feld>
<Feld
id="punkt-beschreibung"
beschriftung="Warum ist der Punkt wichtig?"
erklaerung="Schreib es auf. In sechs Monaten weißt du sonst nicht mehr, warum er auf der Liste steht."
fehler={zustand.felder?.beschreibung}
>
<Textarea name="beschreibung" rows={3} maxLength={2000} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="punkt-faellig" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" />
</Feld>
<Feld
id="punkt-rrule"
beschriftung="Wiederholung"
erklaerung="Leer lassen für einen einmaligen Punkt. Sonst z. B. FREQ=WEEKLY;INTERVAL=1 oder FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1"
fehler={zustand.felder?.wiederholungRrule}
>
<Input name="wiederholungRrule" maxLength={200} />
</Feld>
</div>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Punkt hinzufügen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useActionState, useState } from 'react'
import { Info } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { BEWOHNT_STATUS, BEWOHNT_STATUS_ERKLAERUNG } from '@/lib/immobilie/konstanten'
import { immobilieSpeichern } from '@/server/actions/immobilie'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type ImmobilieWerte = {
id?: string
bezeichnung: string
strasse: string
plz: string
ort: string
grundbuchamt: string
grundbuchBlatt: string
gemarkung: string
flurstueck: string
eigentumsanteil: string
wohnflaecheQm: string
grundstueckQm: string
baujahr: string
bewohntStatus: string
leerstandSeit: string
notizen: string
}
export const LEERE_IMMOBILIE: ImmobilieWerte = {
bezeichnung: '',
strasse: '',
plz: '',
ort: '',
grundbuchamt: '',
grundbuchBlatt: '',
gemarkung: '',
flurstueck: '',
eigentumsanteil: '1/1',
wohnflaecheQm: '',
grundstueckQm: '',
baujahr: '',
bewohntStatus: '',
leerstandSeit: '',
notizen: '',
}
export function ImmobilieFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: ImmobilieWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(immobilieSpeichern, {})
const [bewohnt, setBewohnt] = useState(werte.bewohntStatus)
const istNeu = !werte.id
const leerstehend = bewohnt === 'leerstehend'
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="immobilieId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe
titel="Objekt"
beschreibung="Wie das Grundstück im Alltag heißt und wo es liegt."
>
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du das Objekt wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
className="sm:col-span-2"
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input name="strasse" defaultValue={werte.strasse} maxLength={150} />
</Feld>
<div className="grid grid-cols-[7rem_1fr] gap-3">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input name="plz" defaultValue={werte.plz} inputMode="numeric" maxLength={10} />
</Feld>
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
<Input name="ort" defaultValue={werte.ort} maxLength={100} />
</Feld>
</div>
</Feldgruppe>
<Feldgruppe
titel="Grundbuch"
beschreibung="Die Angaben stehen auf dem Grundbuchauszug, erste Seite. Ohne sie lässt sich beim Grundbuchamt nichts abfragen."
>
<Feld
id="grundbuchamt"
beschriftung="Grundbuchamt"
erklaerung="Das Amtsgericht, bei dem das Grundbuch geführt wird."
fehler={zustand.felder?.grundbuchamt}
>
<Input name="grundbuchamt" defaultValue={werte.grundbuchamt} maxLength={150} />
</Feld>
<Feld
id="grundbuchBlatt"
beschriftung="Grundbuchblatt"
erklaerung="Die Blattnummer, unter der das Grundstück eingetragen ist."
fehler={zustand.felder?.grundbuchBlatt}
>
<Input name="grundbuchBlatt" defaultValue={werte.grundbuchBlatt} maxLength={50} />
</Feld>
<Feld
id="gemarkung"
beschriftung="Gemarkung"
erklaerung="Der Vermessungsbezirk — meist der Ortsname."
fehler={zustand.felder?.gemarkung}
>
<Input name="gemarkung" defaultValue={werte.gemarkung} maxLength={100} />
</Feld>
<Feld
id="flurstueck"
beschriftung="Flurstück"
erklaerung="Die Nummer des Flurstücks, z. B. „412/3“."
fehler={zustand.felder?.flurstueck}
>
<Input name="flurstueck" defaultValue={werte.flurstueck} maxLength={50} />
</Feld>
<Feld
id="eigentumsanteil"
beschriftung="Eigentumsanteil"
erklaerung="Wie viel gehört der betreuten Person? „1/1“ heißt Alleineigentum, „1/2“ hälftiger Miteigentumsanteil."
fehler={zustand.felder?.eigentumsanteil}
>
<Input name="eigentumsanteil" defaultValue={werte.eigentumsanteil} maxLength={20} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Gebäude" beschreibung="Zahlen für die Bewertung und für Anfragen bei Maklern.">
<Feld id="wohnflaecheQm" beschriftung="Wohnfläche in m²" fehler={zustand.felder?.wohnflaecheQm}>
<Input name="wohnflaecheQm" defaultValue={werte.wohnflaecheQm} inputMode="numeric" />
</Feld>
<Feld id="grundstueckQm" beschriftung="Grundstücksfläche in m²" fehler={zustand.felder?.grundstueckQm}>
<Input name="grundstueckQm" defaultValue={werte.grundstueckQm} inputMode="numeric" />
</Feld>
<Feld id="baujahr" beschriftung="Baujahr" fehler={zustand.felder?.baujahr}>
<Input name="baujahr" defaultValue={werte.baujahr} inputMode="numeric" />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Nutzung"
beschreibung="Ob jemand darin wohnt, entscheidet über die Pflichten und über die Behandlung in der Sozialhilfe."
>
<Feld
id="bewohntStatus"
beschriftung="Bewohnt-Status"
erklaerung={bewohnt ? BEWOHNT_STATUS_ERKLAERUNG[bewohnt as keyof typeof BEWOHNT_STATUS_ERKLAERUNG] : undefined}
fehler={zustand.felder?.bewohntStatus}
>
<Select
name="bewohntStatus"
value={bewohnt}
onChange={(e) => setBewohnt(e.target.value)}
>
<option value="">— bitte wählen —</option>
{BEWOHNT_STATUS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</Select>
</Feld>
<Feld
id="leerstandSeit"
beschriftung="Leer stehend seit"
erklaerung="Ab diesem Tag rechnen die Fälligkeiten der Leerstands-Checkliste."
fehler={zustand.felder?.leerstandSeit}
pflicht={leerstehend}
>
<Input name="leerstandSeit" type="date" defaultValue={werte.leerstandSeit} />
</Feld>
</Feldgruppe>
{leerstehend && istNeu && (
<Alert variant="warnung">
<Info aria-hidden="true" />
<AlertTitle>Ein leeres Haus ist kein ruhender Vermögensposten</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Bei Leerstand laufen Pflichten weiter: Der Gebäudeversicherer muss informiert
werden — sonst kann er im Schadensfall leistungsfrei werden. Dazu kommen
Kontrollgänge, Frostschutz, Winterdienst und die Verkehrssicherung.
</p>
<label className="flex items-start gap-2.5">
<Checkbox name="checklisteUebernehmen" defaultChecked className="mt-0.5" />
<span className="text-sm">
Pflichten-Checkliste gleich übernehmen (10 Punkte mit Fälligkeiten, jederzeit
änderbar)
</span>
</label>
</AlertDescription>
</Alert>
)}
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Absendeknopf>{istNeu ? 'Objekt anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
+254
View File
@@ -0,0 +1,254 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { Camera, Package, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { VERBLEIB_MOEGLICHKEITEN } from '@/lib/immobilie/inventar'
import { inventarLoeschen, inventarSpeichern } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type InventarZeile = {
id: string
bezeichnung: string
raum: string | null
verbleib: string | null
bemerkung: string | null
wertCent: number | null
fotos: { id: string; titel: string; aufgenommenAm: string | null }[]
}
export function Inventar({
betreuteId,
immobilieId,
stuecke,
summeCent,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
stuecke: InventarZeile[]
summeCent: number
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(inventarSpeichern, {})
const [offen, setOffen] = useState(false)
return (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="size-5" aria-hidden="true" />
Fotodokumentation
</CardTitle>
<CardDescription>
Fotografiere jeden Raum vollständig, bevor etwas bewegt wird, dazu die
Wertgegenstände einzeln. Das schützt vor dem Vorwurf, es sei etwas verschwunden,
und belegt den Zustand des Hauses gegenüber Käufern und Versicherern.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href={`/dokumente/neu?art=FOTO&immobilieId=${immobilieId}`}>
<Camera aria-hidden="true" />
Fotos hochladen
</Link>
</Button>
<p className="mt-3 text-sm text-muted-foreground">
Die Fotos werden als Dokument mit der Art „Foto“ abgelegt und mit dem Objekt
verknüpft. Einzelne Gegenstände verknüpfst du zusätzlich mit dem passenden
Inventarposten.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="size-5" aria-hidden="true" />
Inventarliste der Wertgegenstände
</CardTitle>
<CardDescription>
Was ist da, wo steht es, was ist es ungefähr wert, und wo ist es geblieben? Die
Liste beantwortet später die Frage von Miterben und Gericht, was mit dem Hausrat
passiert ist.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{stuecke.length === 0 ? (
<Leerzustand
titel="Noch nichts erfasst"
beschreibung="Fang mit dem an, was auffällt: Schmuck, Uhren, Bilder, Werkzeug, ein Fahrzeug. Alltagsgegenstände einzeln aufzunehmen lohnt sich nicht — dafür genügen die Raumfotos."
icon={Package}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Gegenstand aufnehmen
</Button>
)
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Raum</TableHead>
<TableHead className="text-right">geschätzter Wert</TableHead>
<TableHead>Verbleib</TableHead>
<TableHead>Fotos</TableHead>
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{stuecke.map((stueck) => (
<TableRow key={stueck.id}>
<TableCell>
<span className="font-medium">{stueck.bezeichnung}</span>
{stueck.bemerkung && (
<span className="block text-sm text-muted-foreground">
{stueck.bemerkung}
</span>
)}
</TableCell>
<TableCell>{stueck.raum ?? '–'}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(stueck.wertCent)}
</TableCell>
<TableCell>
{stueck.verbleib ? (
<Badge variant="outline">{stueck.verbleib}</Badge>
) : (
'–'
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stueck.fotos.length === 0
? 'keine'
: stueck.fotos
.map((f) => `${f.titel}${f.aufgenommenAm ? ` (${formatDatum(f.aufgenommenAm)})` : ''}`)
.join(', ')}
</TableCell>
{darfSchreiben && (
<TableCell>
<form
action={async () => {
await inventarLoeschen(betreuteId, immobilieId, stueck.id)
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
aria-label={`${stueck.bezeichnung} entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
<p className="text-sm text-muted-foreground">
Summe der geschätzten Werte:{' '}
<strong className="text-foreground">{formatCent(summeCent)}</strong>. Liegt sie
niedrig, gehört das begründet in die Akte — dann lohnt sich eine Verwertung über
einen Händler nicht, und die Kosten einer Haushaltsauflösung sind der
sinnvollere Weg.
</p>
</>
)}
{darfSchreiben && (offen || stuecke.length > 0) && (
<div className="rounded-md border p-4">
{!offen ? (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Weiteren Gegenstand aufnehmen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="inventar-bezeichnung"
beschriftung="Bezeichnung"
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" required maxLength={150} />
</Feld>
<Feld id="inventar-raum" beschriftung="Raum" fehler={zustand.felder?.raum}>
<Input name="raum" maxLength={100} placeholder="Wohnzimmer" />
</Feld>
<Feld
id="inventar-wert"
beschriftung="Geschätzter Wert"
erklaerung="Eigene Schätzung. Wird mit dem heutigen Tag als Stichtag abgelegt."
fehler={zustand.felder?.geschaetzterWert}
>
<Input name="geschaetzterWert" inputMode="decimal" placeholder="250,00" />
</Feld>
<Feld
id="inventar-verbleib"
beschriftung="Verbleib"
fehler={zustand.felder?.verbleib}
>
<Select name="verbleib" defaultValue="im Haus">
{VERBLEIB_MOEGLICHKEITEN.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="inventar-bemerkung"
beschriftung="Bemerkung"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.bemerkung}
>
<Textarea name="bemerkung" rows={2} maxLength={1000} />
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Gegenstand aufnehmen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link'
import { Info, Receipt } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand } from '@/components/seiten-kopf'
import { IMMOBILIEN_KATEGORIE_BESCHRIFTUNG } from '@/lib/immobilie/konstanten'
import type { Kostenjahr } from '@/lib/immobilie/kostenkonto'
import { formatCent } from '@/lib/utils'
/**
* Kostenkonto des Objekts - reine Anzeige.
*
* Die Buchungen legt das Finanzmodul an. Hier steht nur die Auswertung:
* was kostet das Haus im Jahr, ohne dass jemand darin wohnt.
*/
export function Kostenkonto({
jahre,
durchschnittCent,
laufendesJahr,
mehrereObjekte,
}: {
jahre: (Omit<Kostenjahr, 'ausgabenCent' | 'einnahmenCent' | 'saldoCent' | 'ausgaben' | 'einnahmen'> & {
ausgabenCent: number
einnahmenCent: number
saldoCent: number
ausgaben: { kategorie: string; summeCent: number; anzahl: number }[]
einnahmen: { kategorie: string; summeCent: number; anzahl: number }[]
})[]
durchschnittCent: number
laufendesJahr: number
mehrereObjekte: boolean
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-5" aria-hidden="true" />
Kostenkonto
</CardTitle>
<CardDescription>
Was das Objekt kostet — zusammengestellt aus dem Kassenbuch, je Jahr summiert.
Grundsteuer, Betriebskosten (Gebäudeversicherung, Energie, Wasser,
Schornsteinfeger) und Instandhaltung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{jahre.length === 0 ? (
<Leerzustand
titel="Noch keine Buchungen zum Objekt"
beschreibung="Sobald im Kassenbuch Buchungen mit den Kategorien Grundsteuer, Immobilien-Betriebskosten oder Instandhaltung stehen, erscheinen sie hier automatisch."
icon={Receipt}
aktion={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen/neu">Buchung erfassen</Link>
</Button>
}
/>
) : (
<>
{jahre.map((jahr) => (
<div key={jahr.jahr} className="space-y-2">
<h3 className="text-base font-semibold">
{jahr.jahr}
{jahr.jahr === laufendesJahr && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
(läuft noch)
</span>
)}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategorie</TableHead>
<TableHead className="text-right">Buchungen</TableHead>
<TableHead className="text-right">Summe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jahr.ausgaben.map((zeile) => (
<TableRow key={zeile.kategorie}>
<TableCell>
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
] ?? zeile.kategorie}
</TableCell>
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(zeile.summeCent)}
</TableCell>
</TableRow>
))}
{jahr.einnahmen.map((zeile) => (
<TableRow key={zeile.kategorie}>
<TableCell>
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
] ?? zeile.kategorie}
</TableCell>
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
<TableCell className="text-right tabular-nums text-ampel-gruen">
+ {formatCent(zeile.summeCent)}
</TableCell>
</TableRow>
))}
<TableRow className="border-t-2">
<TableCell className="font-semibold">
{jahr.saldoCent < 0 ? 'Kosten im Jahr' : 'Überschuss im Jahr'}
</TableCell>
<TableCell className="text-right tabular-nums">
{jahr.anzahlBuchungen}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
{formatCent(Math.abs(jahr.saldoCent))}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
))}
{durchschnittCent > 0 && (
<p className="text-sm text-muted-foreground">
Durchschnittliche Kosten je abgeschlossenem Jahr:{' '}
<strong className="text-foreground">{formatCent(durchschnittCent)}</strong>. Diese
Zahl gehört in jedes Gespräch über Verkauf, Härtefall oder ein Darlehen nach
§ 91 SGB XII — ein leerstehendes Haus verzehrt Vermögen, auch wenn niemand
darin wohnt.
</p>
)}
</>
)}
<div className="flex items-start gap-2.5 rounded-md border bg-muted/40 p-3">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
Ausgewertet wird über die Buchungskategorien, weil eine Buchung im Datenmodell noch
keinem einzelnen Objekt zugeordnet werden kann.{' '}
{mehrereObjekte ? (
<strong className="text-foreground">
Es sind mehrere Objekte erfasst — die Zahlen enthalten die Kosten aller
Grundstücke zusammen.
</strong>
) : (
'Solange nur ein Objekt geführt wird, ist die Zuordnung eindeutig.'
)}{' '}
Buche die Gebäudeversicherung als Immobilien-Betriebskosten, sonst taucht sie hier
nicht auf.
</p>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,125 @@
'use client'
import { useActionState, useState } from 'react'
import type { ImmobilienStatusSozialhilfe } from '@prisma/client'
import { Landmark } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
IMMOBILIEN_STATUS_BESCHRIFTUNG,
IMMOBILIEN_STATUS_ERKLAERUNG,
IMMOBILIEN_STATUS_RECHTSGRUNDLAGE,
} from '@/lib/immobilie/konstanten'
import { statusSozialhilfeSpeichern } from '@/server/actions/immobilie'
import type { Formularzustand } from '@/lib/validierung/substanz'
const REIHENFOLGE: ImmobilienStatusSozialhilfe[] = [
'UNGEKLAERT',
'SELBST_BEWOHNT_GESCHUETZT',
'HAERTEFALL_GELTEND_GEMACHT',
'DARLEHEN_91_SGB_XII',
'VERKAUF_LAEUFT',
'VERWERTET',
]
export function StatusSozialhilfe({
betreuteId,
immobilieId,
status,
begruendung,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
status: ImmobilienStatusSozialhilfe
begruendung: string | null
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
statusSozialhilfeSpeichern,
{},
)
const [gewaehlt, setGewaehlt] = useState<ImmobilienStatusSozialhilfe>(status)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="size-5" aria-hidden="true" />
Status im Sozialhilfeverfahren
</CardTitle>
<CardDescription>
Wie das Grundstück gegenüber dem Bezirk behandelt wird — und warum. Die Begründung
ist deine Argumentation, keine Rechtsauskunft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{darfSchreiben ? (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<Feld
id="statusSozialhilfe"
beschriftung="Einordnung"
erklaerung={IMMOBILIEN_STATUS_ERKLAERUNG[gewaehlt]}
fehler={zustand.felder?.statusSozialhilfe}
pflicht
>
<Select
name="statusSozialhilfe"
value={gewaehlt}
onChange={(e) => setGewaehlt(e.target.value as ImmobilienStatusSozialhilfe)}
>
{REIHENFOLGE.map((wert) => (
<option key={wert} value={wert}>
{IMMOBILIEN_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="begruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Nenne Datum und Gesprächspartner, wenn die Einordnung auf einer Auskunft des Bezirks beruht."
fehler={zustand.felder?.begruendung}
pflicht={gewaehlt !== 'UNGEKLAERT'}
>
<Textarea name="begruendung" defaultValue={begruendung ?? ''} rows={5} maxLength={4000} />
</Feld>
<Absendeknopf>Status speichern</Absendeknopf>
</form>
) : (
<div className="space-y-2">
<p className="font-medium">{IMMOBILIEN_STATUS_BESCHRIFTUNG[status]}</p>
<p className="text-sm text-muted-foreground">
{IMMOBILIEN_STATUS_ERKLAERUNG[status]}
</p>
{begruendung && <p className="whitespace-pre-line text-sm">{begruendung}</p>}
</div>
)}
<Rechtshinweis rechtsgrundlage={IMMOBILIEN_STATUS_RECHTSGRUNDLAGE} />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: § 90 Abs. 2 Nr. 8 SGB XII schützt ein angemessenes Hausgrundstück,
solange die betreute Person es selbst bewohnt. Nach einem dauerhaften Heimeinzug
sehen die Träger diesen Schutz regelmäßig als entfallen an. Dann bleibt § 91 SGB XII:
Die Hilfe wird als Darlehen gewährt und in der Regel durch eine Grundschuld
gesichert, statt sofort zum Verkauf zu zwingen. Ob im Einzelfall eine Härte nach
§ 90 Abs. 3 SGB XII vorliegt, entscheidet weder diese Anwendung noch der Sachbearbeiter
am Telefon — das gehört schriftlich beantragt und im Zweifel überprüft.
</p>
</CardContent>
</Card>
)
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useActionState, useState } from 'react'
import { LineChart, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { WERTQUELLEN_IMMOBILIE, WERTQUELLE_ERKLAERUNG } from '@/lib/immobilie/konstanten'
import { wertstandErfassen } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type WertZeile = {
id: string
stichtag: string
wertCent: number
wertquelle: string
bemerkung: string | null
}
export function WertHistorie({
betreuteId,
immobilieId,
werte,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
werte: WertZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(wertstandErfassen, {})
const [offen, setOffen] = useState(false)
const [quelle, setQuelle] = useState<string>(WERTQUELLEN_IMMOBILIE[0])
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LineChart className="size-5" aria-hidden="true" />
Wert-Historie
</CardTitle>
<CardDescription>
Jeder Wert bekommt einen Stichtag und eine Quelle. Nichts wird überschrieben — jede
Vermögensübersicht muss Jahre später noch nachvollziehbar sein.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{werte.length === 0 ? (
<Leerzustand
titel="Noch kein Wert hinterlegt"
beschreibung="Der Bodenrichtwert ist kostenlos und schnell zu bekommen (BORIS Bayern, Gutachterausschuss). Er ist keine Bewertung, aber ein belastbarer Anfang."
icon={LineChart}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Wert eintragen
</Button>
)
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead className="text-right">Wert</TableHead>
<TableHead>Quelle</TableHead>
<TableHead>Bemerkung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{werte.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell className="whitespace-nowrap">{formatDatum(zeile.stichtag)}</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatCent(zeile.wertCent)}
</TableCell>
<TableCell>{zeile.wertquelle}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{zeile.bemerkung ?? '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{darfSchreiben && (offen || werte.length > 0) && (
<div className="rounded-md border p-4">
{!offen ? (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Weiteren Wert eintragen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="wert-stichtag" beschriftung="Stichtag" fehler={zustand.felder?.stichtag} pflicht>
<Input name="stichtag" type="date" required />
</Feld>
<Feld
id="wert-betrag"
beschriftung="Wert"
erklaerung="Der Wert des gesamten Objekts, nicht des Miteigentumsanteils."
fehler={zustand.felder?.wert}
pflicht
>
<Input name="wert" inputMode="decimal" placeholder="185.000,00" required />
</Feld>
<Feld
id="wert-quelle"
beschriftung="Wertquelle"
erklaerung={WERTQUELLE_ERKLAERUNG[quelle as keyof typeof WERTQUELLE_ERKLAERUNG]}
fehler={zustand.felder?.wertquelle}
pflicht
className="sm:col-span-2"
>
<Select name="wertquelle" value={quelle} onChange={(e) => setQuelle(e.target.value)}>
{WERTQUELLEN_IMMOBILIE.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</Select>
</Feld>
</div>
<Feld id="wert-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea
name="bemerkung"
rows={2}
maxLength={1000}
placeholder="z. B. Gutachterausschuss Bayreuth, Richtwertzone 12, 210 €/m²"
/>
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Wert aufnehmen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type {
BescheidPruefStatus,
LeistungStatus,
NachforderungStatus,
SozialhilfeBewertung,
} from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { PRUEFSTATUS_BESCHRIFTUNG, PRUEFSTATUS_VARIANTE } from '@/lib/sozialhilfe/bescheide'
import {
LEISTUNG_STATUS_BESCHRIFTUNG,
LEISTUNG_STATUS_VARIANTE,
} from '@/lib/sozialhilfe/leistungskatalog'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_VARIANTE,
} from '@/lib/sozialhilfe/nachforderungen'
import { BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_VARIANTE } from '@/lib/sozialhilfe/schonvermoegen'
/**
* Statuskennzeichen des Moduls.
*
* Alle tragen Text, nicht nur Farbe - wer rot und grün nicht unterscheidet,
* soll trotzdem sehen, woran er ist.
*/
export function NachforderungAbzeichen({ status }: { status: NachforderungStatus }) {
return <Badge variant={NACHFORDERUNG_VARIANTE[status]}>{NACHFORDERUNG_BESCHRIFTUNG[status]}</Badge>
}
export function BescheidAbzeichen({ status }: { status: BescheidPruefStatus }) {
return <Badge variant={PRUEFSTATUS_VARIANTE[status]}>{PRUEFSTATUS_BESCHRIFTUNG[status]}</Badge>
}
export function BewertungAbzeichen({ bewertung }: { bewertung: SozialhilfeBewertung }) {
return <Badge variant={BEWERTUNG_VARIANTE[bewertung]}>{BEWERTUNG_BESCHRIFTUNG[bewertung]}</Badge>
}
export function LeistungAbzeichen({ status }: { status: LeistungStatus }) {
return (
<Badge variant={LEISTUNG_STATUS_VARIANTE[status]}>{LEISTUNG_STATUS_BESCHRIFTUNG[status]}</Badge>
)
}
/** Freie Ampel für berechnete Lagen (Fristen, Deckungslücke). */
export function AmpelAbzeichen({
variante,
text,
}: {
variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
text: string
}) {
return <Badge variant={variante}>{text}</Badge>
}
@@ -0,0 +1,168 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { antragSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type AntragWerte = {
id: string | null
traeger: string
leistungsart: string
aktenzeichen: string
sachbearbeiter: string
telefon: string
email: string
antragAm: string
eingangBeimTraegerAm: string
bewilligtVon: string
bewilligtBis: string
notizen: string
}
/**
* Die Antragsakte: wer bearbeitet den Fall, unter welchem Aktenzeichen,
* seit wann und für welchen Zeitraum bewilligt.
*
* Mehrere Anträge nebeneinander sind der Normalfall - Erstantrag,
* Weiterbewilligung, dazu vielleicht ein eigener Antrag auf Grundsicherung.
* Jeder bekommt seine eigene Akte mit eigenen Nachforderungen und
* Bescheiden.
*/
export function AntragFormular({ werte }: { werte: AntragWerte }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(antragSpeichern, {})
return (
<form action={aktion} className="space-y-6">
{werte.id && <input type="hidden" name="antragId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-6 pt-5">
<Feldgruppe
titel="Träger und Leistung"
beschreibung="In Bayern ist für Leistungen in stationären Einrichtungen der Bezirk zuständig, nicht der Landkreis."
>
<Feld
id="traeger"
beschriftung="Träger"
pflicht
fehler={zustand.felder?.traeger}
erklaerung="Wer entscheidet über den Antrag?"
>
<Input name="traeger" defaultValue={werte.traeger} required />
</Feld>
<Feld
id="leistungsart"
beschriftung="Leistungsart"
pflicht
fehler={zustand.felder?.leistungsart}
erklaerung="z.B. Hilfe zur Pflege nach dem 7. Kapitel SGB XII"
>
<Input name="leistungsart" defaultValue={werte.leistungsart} required />
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichen}
erklaerung="Steht auf jedem Schreiben des Trägers. Gehört in jede Rückmeldung."
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Sachbearbeitung"
beschreibung="Ein Anruf klärt oft schneller als drei Briefe. Deshalb gehört die Durchwahl hierher."
>
<Feld
id="sachbearbeiter"
beschriftung="Sachbearbeiter/in"
fehler={zustand.felder?.sachbearbeiter}
>
<Input name="sachbearbeiter" defaultValue={werte.sachbearbeiter} />
</Feld>
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
<Input name="telefon" type="tel" defaultValue={werte.telefon} />
</Feld>
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
<Input name="email" type="email" defaultValue={werte.email} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Verfahrensdaten"
beschreibung="Das Eingangsdatum beim Träger ist wichtig: Leistungen setzen ab Kenntnis des Trägers ein (§ 18 SGB XII), nicht erst ab der Bewilligung."
>
<Feld
id="antragAm"
beschriftung="Antrag gestellt am"
fehler={zustand.felder?.antragAm}
erklaerung="Datum auf deinem Antragsschreiben."
>
<Input name="antragAm" type="date" defaultValue={werte.antragAm} />
</Feld>
<Feld
id="eingangBeimTraegerAm"
beschriftung="Eingang beim Träger"
fehler={zustand.felder?.eingangBeimTraegerAm}
erklaerung="Ab wann wusste der Träger Bescheid? Eingangsstempel oder Sendebericht aufheben."
>
<Input
name="eingangBeimTraegerAm"
type="date"
defaultValue={werte.eingangBeimTraegerAm}
/>
</Feld>
<Feld
id="bewilligtVon"
beschriftung="Bewilligt von"
fehler={zustand.felder?.bewilligtVon}
>
<Input name="bewilligtVon" type="date" defaultValue={werte.bewilligtVon} />
</Feld>
<Feld
id="bewilligtBis"
beschriftung="Bewilligt bis"
fehler={zustand.felder?.bewilligtBis}
erklaerung="Vor dem Ablauf rechtzeitig die Weiterbewilligung beantragen — sonst reißt die Zahlung ab."
>
<Input name="bewilligtBis" type="date" defaultValue={werte.bewilligtBis} />
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was am Telefon besprochen wurde, gehört hierher."
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Antragsakte anlegen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={werte.id ? `/sozialhilfe/${werte.id}` : '/sozialhilfe'}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button, type ButtonProps } from '@/components/ui/button'
import type { Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Kleinteile, die in allen Formularen des Sozialhilfemoduls
* wiederkehren. Sie liegen hier zusammen, damit nicht jedes Formular
* seinen eigenen Absende-Knopf und seine eigene Meldung erfindet.
*/
/**
* Absende-Knopf, der während des Speicherns gesperrt ist. Ohne die Sperre
* entstehen bei langsamer Verbindung Dubletten - und eine doppelt
* angelegte Nachforderung fällt erst beim Bezirk auf.
*/
export function Absenden({
children = 'Speichern',
laedtText = 'Wird gespeichert …',
disabled,
...props
}: ButtonProps & { laedtText?: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" {...props} disabled={pending || disabled}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? laedtText : children}
</Button>
)
}
/** Rückmeldung einer Server Action - Fehler oder Erfolg. */
export function Meldung({ zustand }: { zustand: Formularzustand }) {
if (zustand.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)
}
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)
}
return null
}
/**
* Ein Knopf, der eine Aktion ohne Formularfelder auslöst und vorher
* nachfragt. Für Löschen und andere Schritte, die man nicht versehentlich
* gehen will.
*/
export function AktionMitRueckfrage({
aktion,
frage,
children,
variant = 'ghost',
size = 'sm',
}: {
aktion: () => Promise<void>
frage: string
children: React.ReactNode
variant?: ButtonProps['variant']
size?: ButtonProps['size']
}) {
return (
<form
action={aktion}
onSubmit={(ereignis) => {
if (!window.confirm(frage)) ereignis.preventDefault()
}}
>
<Absenden variant={variant} size={size} laedtText="Einen Moment …">
{children}
</Absenden>
</form>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import type { BescheidArt } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BESCHEID_ART_BESCHRIFTUNG, BESCHEID_ART_ERKLAERUNG, BESCHEID_ARTEN } from '@/lib/sozialhilfe/bescheide'
import { BELEHRUNG_ERKLAERUNG } from '@/lib/sozialhilfe/rechtsbehelf'
import { bescheidSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
import { Info } from 'lucide-react'
export type BescheidWerte = {
id: string | null
art: BescheidArt
bescheidVom: string
zugegangenAm: string
zeitraumVon: string
zeitraumBis: string
betrag: string
rechtsbehelfsfristMonate: number
widerspruchEingelegtAm: string
notizen: string
}
export const LEERER_BESCHEID: BescheidWerte = {
id: null,
art: 'BEWILLIGUNG',
bescheidVom: '',
zugegangenAm: '',
zeitraumVon: '',
zeitraumBis: '',
betrag: '',
rechtsbehelfsfristMonate: 1,
widerspruchEingelegtAm: '',
notizen: '',
}
/**
* Bescheid erfassen.
*
* Das wichtigste Feld ist das Zugangsdatum, nicht das Datum auf dem
* Bescheid. Die Widerspruchsfrist läuft ab Bekanntgabe (§ 84 Abs. 1 SGG) -
* zwischen Ausstellung und Zugang liegen fast immer mehrere Tage, und wer
* vom falschen Tag aus rechnet, verschenkt sie.
*/
export function BescheidFormular({
antragId,
werte,
zugangsvorschlag,
}: {
antragId: string
werte: BescheidWerte
/** Vorschlag nach § 37 Abs. 2 SGB X, sobald ein Bescheiddatum steht. */
zugangsvorschlag?: { datum: string; hinweis: string } | null
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidSpeichern, {})
const [art, setArt] = useState<BescheidArt>(werte.art)
const [monate, setMonate] = useState(String(werte.rechtsbehelfsfristMonate))
const [zugegangenAm, setZugegangenAm] = useState(werte.zugegangenAm)
return (
<form action={aktion} className="space-y-6">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="bescheidId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-5 pt-5">
<Feld
id="art"
beschriftung="Art des Bescheids"
pflicht
fehler={zustand.felder?.art}
erklaerung={BESCHEID_ART_ERKLAERUNG[art]}
>
<Select name="art" value={art} onChange={(e) => setArt(e.target.value as BescheidArt)}>
{BESCHEID_ARTEN.map((a) => (
<option key={a} value={a}>
{BESCHEID_ART_BESCHRIFTUNG[a]}
</option>
))}
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bescheidVom"
beschriftung="Bescheid vom"
pflicht
fehler={zustand.felder?.bescheidVom}
erklaerung="Das Datum, das oben auf dem Schreiben steht."
>
<Input name="bescheidVom" type="date" defaultValue={werte.bescheidVom} required />
</Feld>
<Feld
id="zugegangenAm"
beschriftung="Zugegangen am"
fehler={zustand.felder?.zugegangenAm}
erklaerung="Maßgeblich für den Fristbeginn — nicht das Datum auf dem Bescheid. Hebe den Briefumschlag mit dem Poststempel auf."
>
<Input
name="zugegangenAm"
type="date"
value={zugegangenAm}
onChange={(e) => setZugegangenAm(e.target.value)}
/>
</Feld>
</div>
{zugangsvorschlag && zugegangenAm === '' && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertTitle>Zugangsvermutung</AlertTitle>
<AlertDescription className="space-y-2">
<p>{zugangsvorschlag.hinweis}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setZugegangenAm(zugangsvorschlag.datum)}
>
{zugangsvorschlag.datum.split('-').reverse().join('.')} übernehmen
</Button>
</AlertDescription>
</Alert>
)}
<Feld
id="rechtsbehelfsfristMonate"
beschriftung="Rechtsbehelfsbelehrung"
fehler={zustand.felder?.rechtsbehelfsfristMonate}
erklaerung={
BELEHRUNG_ERKLAERUNG[monate === '12' ? 'FEHLT_ODER_FEHLERHAFT' : 'ORDNUNGSGEMAESS']
}
>
<Select
name="rechtsbehelfsfristMonate"
value={monate}
onChange={(e) => setMonate(e.target.value)}
>
<option value="1">Vorhanden und richtig — ein Monat (§ 84 SGG)</option>
<option value="12">Fehlt oder ist fehlerhaft — ein Jahr (§ 66 SGG)</option>
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="zeitraumVon"
beschriftung="Zeitraum von"
fehler={zustand.felder?.zeitraumVon}
>
<Input name="zeitraumVon" type="date" defaultValue={werte.zeitraumVon} />
</Feld>
<Feld id="zeitraumBis" beschriftung="Zeitraum bis" fehler={zustand.felder?.zeitraumBis}>
<Input name="zeitraumBis" type="date" defaultValue={werte.zeitraumBis} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="Monatlicher oder einmaliger Betrag laut Bescheid, z.B. 1.234,56"
>
<Input name="betrag" inputMode="decimal" defaultValue={werte.betrag} />
</Feld>
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Nur ausfüllen, wenn der Widerspruch bereits raus ist."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={werte.widerspruchEingelegtAm}
/>
</Feld>
</div>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was ist auffällig? Welche Position stimmt nicht?"
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
{zugegangenAm !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" defaultChecked />
<span>
Frist „Widerspruch möglich bis“ im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Mit dem berechneten Fristende und Vorwarnung 21, 14, 7, 3 und 1 Tag vorher. Der
Rechenweg steht in der Beschreibung der Frist.
</span>
</span>
</label>
)}
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 66 SGG" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Bescheid erfassen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={`/sozialhilfe/${antragId}`}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
@@ -0,0 +1,200 @@
'use client'
import { useActionState, useState } from 'react'
import type { BescheidPruefStatus } from '@prisma/client'
import { Scale } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
PRUEFSTATUS,
PRUEFSTATUS_BESCHRIFTUNG,
PRUEFSTATUS_ERKLAERUNG,
} from '@/lib/sozialhilfe/bescheide'
import { bescheidStatusSetzen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die Prüfansicht eines Bescheids: Rechenweg der Widerspruchsfrist,
* Ampel und Statuswechsel.
*
* Der Rechenweg steht ausgeschrieben da, weil die Anwendung sich irren
* kann. Wer eine Frist verpasst, soll sehen, wo die Rechnung abgebogen
* ist - und nicht einer Zahl vertrauen müssen, die aus dem Nichts kommt.
*/
export type Rechenweg = {
berechenbar: boolean
widerspruchMoeglichBis: string | null
rechnerischesEnde: string | null
verschoben: boolean
tageVerbleibend: number | null
abgelaufen: boolean
ampel: { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string }
schritte: { titel: string; text: string; rechtsgrundlage: string }[]
hinweise: string[]
}
function alsAnzeige(iso: string | null): string {
return iso ? iso.split('-').reverse().join('.') : '–'
}
export function BescheidPruefung({
bescheidId,
pruefStatus,
widerspruchEingelegtAm,
rechenweg,
darfSchreiben,
}: {
bescheidId: string
pruefStatus: BescheidPruefStatus
widerspruchEingelegtAm: string
rechenweg: Rechenweg
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidStatusSetzen, {})
const [status, setStatus] = useState<BescheidPruefStatus>(pruefStatus)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Widerspruchsfrist</CardTitle>
<CardDescription>
Berechnet aus dem Zugangsdatum. Die Anwendung rechnet — entscheiden tun der Träger
und im Streitfall das Sozialgericht.
</CardDescription>
</div>
<Badge variant={rechenweg.ampel.variante}>{rechenweg.ampel.text}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{rechenweg.berechenbar ? (
<>
<div className="rounded-lg border bg-muted/40 p-4">
<p className="text-sm text-muted-foreground">Widerspruch möglich bis</p>
<p className="text-2xl font-semibold tabular-nums">
{alsAnzeige(rechenweg.widerspruchMoeglichBis)}
</p>
{rechenweg.verschoben && (
<p className="mt-1 text-sm text-muted-foreground">
Rechnerisch wäre es der {alsAnzeige(rechenweg.rechnerischesEnde)} gewesen —
verschoben auf den nächsten Werktag.
</p>
)}
</div>
<ol className="space-y-3">
{rechenweg.schritte.map((schritt, i) => (
<li key={i} className="flex gap-3">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
aria-hidden="true"
>
{i + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{schritt.titel}</p>
<p className="text-sm text-muted-foreground">{schritt.text}</p>
<p className="mt-0.5 text-xs font-medium text-muted-foreground">
{schritt.rechtsgrundlage}
</p>
</div>
</li>
))}
</ol>
</>
) : (
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>Frist noch nicht berechenbar</AlertTitle>
<AlertDescription>
Ohne Zugangsdatum lässt sich nichts rechnen. Trag oben ein, wann der Bescheid im
Briefkasten lag.
</AlertDescription>
</Alert>
)}
{rechenweg.hinweise.map((hinweis, i) => (
<Alert key={i} variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>{hinweis}</AlertDescription>
</Alert>
))}
<Rechtshinweis rechtsgrundlage="§ 84 SGG" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Prüfstand</CardTitle>
<CardDescription>{PRUEFSTATUS_ERKLAERUNG[pruefStatus]}</CardDescription>
</div>
<BescheidAbzeichen status={pruefStatus} />
</div>
</CardHeader>
<CardContent>
{darfSchreiben ? (
<form action={aktion} className="space-y-4">
<input type="hidden" name="bescheidId" value={bescheidId} />
<Meldung zustand={zustand} />
<Feld
id="pruefStatus"
beschriftung="Neuer Stand"
fehler={zustand.felder?.pruefStatus}
erklaerung={PRUEFSTATUS_ERKLAERUNG[status]}
>
<Select
name="pruefStatus"
value={status}
onChange={(e) => setStatus(e.target.value as BescheidPruefStatus)}
>
{PRUEFSTATUS.map((s) => (
<option key={s} value={s}>
{PRUEFSTATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
{status === 'WIDERSPRUCH_EINGELEGT' && (
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
pflicht
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Der Tag, an dem der Widerspruch beim Träger eingegangen ist — nicht der Tag, an dem du ihn geschrieben hast."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={widerspruchEingelegtAm}
/>
</Feld>
)}
<Absenden variant="outline">Stand übernehmen</Absenden>
</form>
) : (
<p className="text-sm text-muted-foreground">
Zum Ändern des Prüfstands fehlt dir die Schreibberechtigung.
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,180 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung } from '@prisma/client'
import { BookOpen, ChevronDown } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Argumentationshilfe } from '@/lib/sozialhilfe/schonvermoegen'
import { ALLE_BEWERTUNGEN, BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { bewertungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Bewertung eines Vermögenspostens oder Vertrags im Sozialhilfeverfahren.
*
* Die Argumentationshilfen sind Textbausteine zum Übernehmen, keine
* Automatik: Die Anwendung setzt nie selbst eine Bewertung. Sie schlägt
* eine Richtung vor, liefert die Fundstelle und den Formulierungsvorschlag
* - eingeordnet wird von Hand, und über die Anrechnung entscheidet
* ohnehin der Träger.
*/
export function BewertungFormular({
herkunft,
id,
bezeichnung,
bewertung,
begruendung,
hilfen,
darfSchreiben,
beimSpeichern,
}: {
herkunft: 'VERMOEGEN' | 'VERTRAG'
id: string
bezeichnung: string
bewertung: SozialhilfeBewertung
begruendung: string
hilfen: Argumentationshilfe[]
darfSchreiben: boolean
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await bewertungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [auswahl, setAuswahl] = useState<SozialhilfeBewertung>(bewertung)
const [text, setText] = useState(begruendung)
const [offeneHilfe, setOffeneHilfe] = useState<string | null>(null)
if (!darfSchreiben) {
return (
<div className="space-y-2">
<p className="text-sm">
<span className="font-medium">{BEWERTUNG_BESCHRIFTUNG[bewertung]}</span>
</p>
{begruendung ? (
<p className="whitespace-pre-line text-sm text-muted-foreground">{begruendung}</p>
) : (
<p className="text-sm text-muted-foreground">Keine Begründung hinterlegt.</p>
)}
</div>
)
}
const uebernehmen = (hilfe: Argumentationshilfe) => {
setText((vorher) => (vorher.trim() === '' ? hilfe.textbaustein : `${vorher}\n\n${hilfe.textbaustein}`))
setAuswahl((vorher) => (vorher === 'UNGEPRUEFT' ? hilfe.vorschlag : vorher))
}
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="herkunft" value={herkunft} />
<input type="hidden" name="id" value={id} />
<Meldung zustand={zustand} />
<Feld
id={`bewertung-${id}`}
beschriftung={`Einschätzung zu „${bezeichnung}“`}
fehler={zustand.felder?.bewertung}
erklaerung={BEWERTUNG_ERKLAERUNG[auswahl]}
>
<Select
name="bewertung"
value={auswahl}
onChange={(e) => setAuswahl(e.target.value as SozialhilfeBewertung)}
>
{ALLE_BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id={`begruendung-${id}`}
beschriftung="Begründung"
fehler={zustand.felder?.begruendung}
erklaerung="Wird verschlüsselt gespeichert. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter — und in einem halben Jahr weißt du selbst nicht mehr, warum."
>
<Textarea
name="begruendung"
value={text}
onChange={(e) => setText(e.target.value)}
rows={8}
/>
</Feld>
{hilfen.length > 0 && (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<BookOpen className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">Argumentationshilfen</span>
</div>
<ul className="divide-y">
{hilfen.map((hilfe) => (
<li key={hilfe.schluessel} className="px-4 py-3">
<button
type="button"
onClick={() =>
setOffeneHilfe((v) => (v === hilfe.schluessel ? null : hilfe.schluessel))
}
aria-expanded={offeneHilfe === hilfe.schluessel}
className="flex w-full items-start justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block text-sm font-medium">{hilfe.titel}</span>
<span className="block text-sm text-muted-foreground">{hilfe.kurz}</span>
<span className="mt-0.5 block text-xs font-medium text-muted-foreground">
{hilfe.rechtsgrundlage}
</span>
</span>
<ChevronDown
className={`mt-1 size-4 shrink-0 transition-transform ${
offeneHilfe === hilfe.schluessel ? 'rotate-180' : ''
}`}
aria-hidden="true"
/>
</button>
{offeneHilfe === hilfe.schluessel && (
<div className="mt-3 space-y-3">
<div className="rounded-md border border-ampel-orange/40 bg-ampel-orange/10 p-3">
<p className="text-xs font-semibold uppercase tracking-wide">
Worauf zu achten ist
</p>
<p className="mt-1 text-sm">{hilfe.worauf_achten}</p>
</div>
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-3 font-sans text-sm">
{hilfe.textbaustein}
</pre>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => uebernehmen(hilfe)}
>
In die Begründung übernehmen
</Button>
</div>
)}
</li>
))}
</ul>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" />
<Absenden>Bewertung speichern</Absenden>
</form>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useActionState, useState } from 'react'
import { ListChecks } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Checklistenposition } from '@/lib/sozialhilfe/checkliste'
import { checklisteUebernehmen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die vorbefüllte Checkliste der Standardunterlagen.
*
* Zu jeder Position steht, warum der Träger sie will. Das ist der eigentliche
* Wert dieser Liste: Wer den Zweck kennt, beschafft das Richtige beim ersten
* Mal - und erkennt, wenn eine Nachforderung über das Nötige hinausgeht.
*/
export function ChecklistePanel({
antragId,
offen,
bereitsUebernommen,
}: {
antragId: string
offen: Checklistenposition[]
bereitsUebernommen: number
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checklisteUebernehmen, {})
const [ausgewaehlt, setAusgewaehlt] = useState<Set<string>>(new Set())
const umschalten = (schluessel: string) => {
setAusgewaehlt((vorher) => {
const neu = new Set(vorher)
if (neu.has(schluessel)) neu.delete(schluessel)
else neu.add(schluessel)
return neu
})
}
const alleWaehlen = () => {
setAusgewaehlt((vorher) =>
vorher.size === offen.length ? new Set() : new Set(offen.map((p) => p.schluessel)),
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListChecks className="size-5" aria-hidden="true" />
Standardunterlagen
</CardTitle>
<CardDescription>
Diesen Stapel fordert der Bezirk bei einem Antrag auf Hilfe zur Pflege fast immer an. Wer
ihn vorher beschafft, spart Wochen.{' '}
{bereitsUebernommen > 0 && (
<>
{bereitsUebernommen} von {bereitsUebernommen + offen.length} stehen schon im Tracker.
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{offen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Alle Standardunterlagen stehen bereits im Tracker. Weitere Anforderungen des Trägers
trägst du dort einzeln nach.
</p>
) : (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
<Meldung zustand={zustand} />
<button
type="button"
onClick={alleWaehlen}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{ausgewaehlt.size === offen.length ? 'Auswahl aufheben' : 'Alle auswählen'}
</button>
<ul className="max-h-[28rem] space-y-2 overflow-y-auto rounded-lg border p-2">
{offen.map((position) => (
<li key={position.schluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-accent/50">
<Checkbox
name="schluessel"
value={position.schluessel}
checked={ausgewaehlt.has(position.schluessel)}
onChange={() => umschalten(position.schluessel)}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium">{position.bezeichnung}</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
{position.erlaeuterung}
</span>
{position.rechtsgrundlage && (
<span className="mt-1 block text-xs font-medium text-muted-foreground">
{position.rechtsgrundlage}
</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="checkliste-angefordertAm"
beschriftung="Angefordert am"
erklaerung="Datum des Anforderungsschreibens — gilt für alle ausgewählten Positionen."
>
<Input name="angefordertAm" type="date" />
</Feld>
<Feld
id="checkliste-fristBis"
beschriftung="Frist des Trägers"
erklaerung="Optional. Kannst du später je Zeile anpassen."
>
<Input name="fristBis" type="date" />
</Feld>
</div>
<Absenden disabled={ausgewaehlt.size === 0}>
{ausgewaehlt.size === 0
? 'Nichts ausgewählt'
: `${ausgewaehlt.size} ${
ausgewaehlt.size === 1 ? 'Unterlage' : 'Unterlagen'
} übernehmen`}
</Absenden>
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</form>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,326 @@
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
vorschlagPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/** Pauschale der Pflegekasse zum hinterlegten Pflegegrad, als "1319,00". */
vorschlagPflegekasse: { betrag: string; text: string } | null
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [pflegekasse, setPflegekasse] = useState(werte.pflegekasse)
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse"
fehler={zustand.felder?.pflegekasse}
erklaerung={
vorschlagPflegekasse ? (
<>
{vorschlagPflegekasse.text}{' '}
<button
type="button"
onClick={() => setPflegekasse(vorschlagPflegekasse.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
) : (
'Pauschale nach § 43 SGB XI. Hinterlege den Pflegegrad in den Stammdaten, dann steht hier ein Vorschlag.'
)
}
>
<Input
name="pflegekasse"
inputMode="decimal"
value={pflegekasse}
onChange={(e) => setPflegekasse(e.target.value)}
/>
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
@@ -0,0 +1,85 @@
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCent } from '@/lib/utils'
import { monatsname } from '@/lib/sozialhilfe/heimkosten'
/**
* Jahresverlauf der Gegenüberstellung.
*
* Server Component: Die Beträge bleiben BigInt bis zur Anzeige, es geht
* nichts über die Grenze zum Browser. Die Tabelle scrollt in ihrem eigenen
* Rahmen - am Handy darf nie die ganze Seite seitlich wandern.
*/
export type MonatsZeile = {
jahr: number
monat: number
heimkostenCent: bigint
gesamtbedarfCent: bigint
deckungCent: bigint
deckungslueckeCent: bigint
ueberdeckungCent: bigint
abwesenheitstage: number
erfasst: boolean
}
export function HeimkostenTabelle({ zeilen }: { zeilen: MonatsZeile[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Monat</TableHead>
<TableHead className="text-right">Heimkosten</TableHead>
<TableHead className="text-right">Gesamtbedarf</TableHead>
<TableHead className="text-right">Deckung</TableHead>
<TableHead className="text-right">Ergebnis</TableHead>
<TableHead className="text-right">Abwesend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<TableRow key={`${zeile.jahr}-${zeile.monat}`}>
<TableCell className="font-medium">
{monatsname(zeile.monat)}
{!zeile.erfasst && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
nicht erfasst
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.heimkostenCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.gesamtbedarfCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.deckungCent) : '–'}
</TableCell>
<TableCell className="text-right">
{!zeile.erfasst ? (
'–'
) : zeile.deckungslueckeCent > 0n ? (
<Badge variant="rot">Lücke {formatCent(zeile.deckungslueckeCent)}</Badge>
) : zeile.ueberdeckungCent > 0n ? (
<Badge variant="gruen">Überschuss {formatCent(zeile.ueberdeckungCent)}</Badge>
) : (
<Badge variant="gruen">gedeckt</Badge>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? zeile.abwesenheitstage : '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useActionState, useState } from 'react'
import type { LeistungStatus } from '@prisma/client'
import { CalendarClock, Plus } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { LeistungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import type { Katalogleistung } from '@/lib/sozialhilfe/leistungskatalog'
import { LEISTUNG_STATUS, LEISTUNG_STATUS_BESCHRIFTUNG } from '@/lib/sozialhilfe/leistungskatalog'
import { formatCent, formatDatum } from '@/lib/utils'
import { katalogUebernehmen, leistungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type LeistungZeile = {
id: string
katalogSchluessel: string | null
bezeichnung: string
traeger: string
rechtsgrundlage: string
status: LeistungStatus
beantragtAm: string | null
bewilligtAm: string | null
gueltigBis: string | null
betragCent: number | null
jaehrlichNeuBeantragen: boolean
notizen: string
/** Aus dem Katalog nachgeschlagen, für den Ausklapp-Bereich. */
katalog: Katalogleistung | null
}
/**
* Katalog weiterer Leistungen mit pflegbarem Status.
*
* Der Katalog steht daneben, damit man nicht raten muss, was man für den
* Antrag braucht. Die jährlich zu wiederholenden Anträge tragen einen
* eigenen Hinweis - sie sind es, die untergehen.
*/
export function LeistungenListe({
leistungen,
offeneKatalogeintraege,
darfSchreiben,
}: {
leistungen: LeistungZeile[]
offeneKatalogeintraege: Katalogleistung[]
darfSchreiben: boolean
}) {
const [katalogZustand, katalogAktion] = useActionState<Formularzustand, FormData>(
katalogUebernehmen,
{},
)
const [offen, setOffen] = useState<string | null>(null)
const jaehrlich = leistungen.filter(
(l) => l.jaehrlichNeuBeantragen && l.status !== 'NICHT_ZUTREFFEND',
)
return (
<div className="space-y-6">
{jaehrlich.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertDescription>
<span className="font-medium">Jährlich neu zu beantragen: </span>
{jaehrlich.map((l) => l.bezeichnung).join(', ')}. Das bayerische Landespflegegeld
verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12. gestellt ist — leg dir dafür
eine Frist an.
</AlertDescription>
</Alert>
)}
{offeneKatalogeintraege.length > 0 && darfSchreiben && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="size-5" aria-hidden="true" />
Aus dem Katalog übernehmen
</CardTitle>
<CardDescription>
Leistungen, die noch nicht in der Liste stehen. Übernommen wird mit Träger,
Rechtsgrundlage und dem Hinweis, was man dafür braucht.
</CardDescription>
</CardHeader>
<CardContent>
<form action={katalogAktion} className="space-y-4">
<Meldung zustand={katalogZustand} />
<ul className="space-y-2">
{offeneKatalogeintraege.map((k) => (
<li key={k.katalogSchluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/50">
<Checkbox name="schluessel" value={k.katalogSchluessel} className="mt-1" />
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{k.bezeichnung}</span>
{k.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich</Badge>}
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">{k.kurz}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{k.traeger} · {k.rechtsgrundlage}
</span>
</span>
</label>
</li>
))}
</ul>
<Absenden variant="outline">Ausgewählte übernehmen</Absenden>
</form>
</CardContent>
</Card>
)}
<ul className="space-y-3">
{leistungen.map((l) => (
<li key={l.id}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{l.bezeichnung}</span>
<LeistungAbzeichen status={l.status} />
{l.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich neu</Badge>}
</div>
<p className="text-sm text-muted-foreground">
{l.traeger}
{l.rechtsgrundlage ? ` · ${l.rechtsgrundlage}` : ''}
</p>
<p className="text-sm">
{l.betragCent !== null && (
<span className="font-medium tabular-nums">{formatCent(l.betragCent)} </span>
)}
{l.beantragtAm && <>Beantragt am {formatDatum(l.beantragtAm)}. </>}
{l.bewilligtAm && <>Bewilligt am {formatDatum(l.bewilligtAm)}. </>}
{l.gueltigBis && <>Gültig bis {formatDatum(l.gueltigBis)}.</>}
</p>
</div>
{darfSchreiben && (
<Button
variant="outline"
size="sm"
onClick={() => setOffen((v) => (v === l.id ? null : l.id))}
>
{offen === l.id ? 'Zuklappen' : 'Bearbeiten'}
</Button>
)}
</div>
{l.katalog && (
<details className="rounded-md border bg-muted/30 p-3">
<summary className="cursor-pointer text-sm font-medium">
Voraussetzungen und benötigte Unterlagen
</summary>
<p className="mt-2 text-sm">{l.katalog.voraussetzung}</p>
{l.katalog.betragHinweis && (
<p className="mt-2 text-sm text-muted-foreground">{l.katalog.betragHinweis}</p>
)}
<p className="mt-2 text-sm font-medium">Dafür brauchst du:</p>
<ul className="mt-1 list-inside list-disc text-sm text-muted-foreground">
{l.katalog.benoetigt.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</details>
)}
{offen === l.id && darfSchreiben && (
<div className="border-t pt-4">
<LeistungFormular zeile={l} beimSpeichern={() => setOffen(null)} />
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
<Rechtshinweis rechtsgrundlage="SGB XII, SGB V, SGB IX, BayLPflGG" />
</div>
)
}
function LeistungFormular({
zeile,
beimSpeichern,
}: {
zeile: LeistungZeile
beimSpeichern: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await leistungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern()
return ergebnis
},
{},
)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="leistungId" value={zeile.id} />
<Meldung zustand={zustand} />
<Feld id={`bezeichnung-${zeile.id}`} beschriftung="Bezeichnung" pflicht fehler={zustand.felder?.bezeichnung}>
<Input name="bezeichnung" defaultValue={zeile.bezeichnung} required />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id={`traeger-${zeile.id}`} beschriftung="Träger" fehler={zustand.felder?.traeger}>
<Input name="traeger" defaultValue={zeile.traeger} />
</Feld>
<Feld
id={`rechtsgrundlage-${zeile.id}`}
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
>
<Input name="rechtsgrundlage" defaultValue={zeile.rechtsgrundlage} />
</Feld>
<Feld id={`status-${zeile.id}`} beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={zeile.status}>
{LEISTUNG_STATUS.map((s) => (
<option key={s} value={s}>
{LEISTUNG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`betrag-${zeile.id}`}
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="z.B. 1.000,00 für das Landespflegegeld."
>
<Input
name="betrag"
inputMode="decimal"
defaultValue={
zeile.betragCent === null
? ''
: `${Math.trunc(zeile.betragCent / 100)},${String(Math.abs(zeile.betragCent % 100)).padStart(2, '0')}`
}
/>
</Feld>
<Feld
id={`beantragtAm-${zeile.id}`}
beschriftung="Beantragt am"
fehler={zustand.felder?.beantragtAm}
>
<Input name="beantragtAm" type="date" defaultValue={zeile.beantragtAm ?? ''} />
</Feld>
<Feld
id={`bewilligtAm-${zeile.id}`}
beschriftung="Bewilligt am"
fehler={zustand.felder?.bewilligtAm}
>
<Input name="bewilligtAm" type="date" defaultValue={zeile.bewilligtAm ?? ''} />
</Feld>
<Feld
id={`gueltigBis-${zeile.id}`}
beschriftung="Gültig bis"
fehler={zustand.felder?.gueltigBis}
erklaerung="Danach ist ein neuer Nachweis fällig."
>
<Input name="gueltigBis" type="date" defaultValue={zeile.gueltigBis ?? ''} />
</Feld>
</div>
<label className="flex items-start gap-3 text-sm">
<Checkbox
name="jaehrlichNeuBeantragen"
defaultChecked={zeile.jaehrlichNeuBeantragen}
className="mt-0.5"
/>
<span>Muss jährlich neu beantragt werden</span>
</label>
<Feld id={`notizen-${zeile.id}`} beschriftung="Notizen" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={zeile.notizen} rows={3} />
</Feld>
<Absenden>Speichern</Absenden>
</form>
)
}
@@ -0,0 +1,184 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_ERKLAERUNG,
NACHFORDERUNG_STATUS,
} from '@/lib/sozialhilfe/nachforderungen'
import { nachforderungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type NachforderungWerte = {
id: string | null
bezeichnung: string
erlaeuterung: string
status: NachforderungStatus
angefordertAm: string
fristBis: string
uebersandtAm: string
begruendungNichtVorhanden: string
reihenfolge: number
ausCheckliste: boolean
}
export const LEERE_NACHFORDERUNG: NachforderungWerte = {
id: null,
bezeichnung: '',
erlaeuterung: '',
status: 'OFFEN',
angefordertAm: '',
fristBis: '',
uebersandtAm: '',
begruendungNichtVorhanden: '',
reihenfolge: 0,
ausCheckliste: false,
}
/**
* Eine Zeile des Trackers anlegen oder ändern.
*
* Das Begründungsfeld erscheint, sobald "Nicht vorhanden" gewählt ist.
* Es ist dann Pflicht: Eine unbeantwortete Zeile sieht beim Träger wie
* fehlende Mitwirkung aus (§ 60, § 66 SGB I), eine begründete Fehlanzeige
* dagegen ist eine Antwort.
*/
export function NachforderungFormular({
antragId,
werte,
beimSpeichern,
}: {
antragId: string
werte: NachforderungWerte
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await nachforderungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [status, setStatus] = useState<NachforderungStatus>(werte.status)
const [fristBis, setFristBis] = useState(werte.fristBis)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="nachforderungId" value={werte.id} />}
<input type="hidden" name="reihenfolge" value={werte.reihenfolge} />
<Meldung zustand={zustand} />
<Feld
id={`bezeichnung-${werte.id ?? 'neu'}`}
beschriftung="Welche Unterlage?"
pflicht
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required />
</Feld>
<Feld
id={`erlaeuterung-${werte.id ?? 'neu'}`}
beschriftung="Erläuterung"
fehler={zustand.felder?.erlaeuterung}
erklaerung="Wozu braucht der Träger das? Wer den Zweck notiert, kann Rückfragen vorwegnehmen."
>
<Textarea name="erlaeuterung" defaultValue={werte.erlaeuterung} rows={3} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id={`status-${werte.id ?? 'neu'}`}
beschriftung="Status"
fehler={zustand.felder?.status}
erklaerung={NACHFORDERUNG_ERKLAERUNG[status]}
>
<Select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value as NachforderungStatus)}
>
{NACHFORDERUNG_STATUS.map((s) => (
<option key={s} value={s}>
{NACHFORDERUNG_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`angefordertAm-${werte.id ?? 'neu'}`}
beschriftung="Angefordert am"
fehler={zustand.felder?.angefordertAm}
erklaerung="Datum des Schreibens vom Träger."
>
<Input name="angefordertAm" type="date" defaultValue={werte.angefordertAm} />
</Feld>
<Feld
id={`fristBis-${werte.id ?? 'neu'}`}
beschriftung="Frist des Trägers"
fehler={zustand.felder?.fristBis}
>
<Input
name="fristBis"
type="date"
value={fristBis}
onChange={(e) => setFristBis(e.target.value)}
/>
</Feld>
<Feld
id={`uebersandtAm-${werte.id ?? 'neu'}`}
beschriftung="Übersandt am"
fehler={zustand.felder?.uebersandtAm}
erklaerung="Der Nachweis der Mitwirkung, falls der Träger später von einer Lücke ausgeht."
>
<Input name="uebersandtAm" type="date" defaultValue={werte.uebersandtAm} />
</Feld>
</div>
{status === 'NICHT_VORHANDEN' && (
<Feld
id={`begruendung-${werte.id ?? 'neu'}`}
beschriftung="Warum gibt es die Unterlage nicht?"
pflicht
fehler={zustand.felder?.begruendungNichtVorhanden}
erklaerung="Dieser Text geht mit ins Unterlagenpaket. Beispiel: „Ein Mietvertrag existiert nicht, die Wohnung wurde 1978 als Eigentum erworben.“"
>
<Textarea
name="begruendungNichtVorhanden"
defaultValue={werte.begruendungNichtVorhanden}
rows={3}
required
/>
</Feld>
)}
{fristBis !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" />
<span>
Frist im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Legt eine behördliche Frist zum {fristBis.split('-').reverse().join('.')} an, mit
Vorwarnung 14, 7, 3 und 1 Tag vorher.
</span>
</span>
</label>
)}
<Absenden>{werte.id ? 'Änderungen speichern' : 'Nachforderung aufnehmen'}</Absenden>
</form>
)
}
@@ -0,0 +1,301 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { CalendarClock, FileText, Pencil, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { AktionMitRueckfrage, Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { NachforderungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
LEERE_NACHFORDERUNG,
NachforderungFormular,
type NachforderungWerte,
} from '@/components/sozialhilfe/nachforderung-formular'
import { fristAmpel, trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
import { formatDatum } from '@/lib/utils'
import {
nachforderungLoeschen,
nachforderungStatusSetzen,
type Formularzustand,
} from '@/server/actions/sozialhilfe'
/**
* Der Nachforderungs-Tracker.
*
* Solange auch nur eine Zeile offen ist, ruht das Verfahren. Deshalb steht
* der Balken oben und die überfälligen Zeilen darüber - nicht als
* Statistik, sondern als Arbeitsliste.
*
* Alle Datumswerte kommen als ISO-Zeichenkette aus der Server Component;
* Date-Objekte und BigInt überstehen die Grenze zum Browser nicht.
*/
export type TrackerEintrag = {
id: string
bezeichnung: string
erlaeuterung: string | null
status: NachforderungStatus
angefordertAm: string | null
fristBis: string | null
uebersandtAm: string | null
begruendungNichtVorhanden: string | null
reihenfolge: number
ausCheckliste: boolean
anzahlDokumente: number
}
function alsDatum(iso: string | null): Date | null {
return iso ? new Date(`${iso}T00:00:00.000Z`) : null
}
export function NachforderungTracker({
antragId,
eintraege,
darfSchreiben,
}: {
antragId: string
eintraege: TrackerEintrag[]
darfSchreiben: boolean
}) {
const [neuOffen, setNeuOffen] = useState(false)
const fortschritt = trackerFortschritt(
eintraege.map((e) => ({
id: e.id,
bezeichnung: e.bezeichnung,
status: e.status,
fristBis: alsDatum(e.fristBis),
})),
)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Nachforderungen</CardTitle>
<CardDescription>
Jede vom Träger angeforderte Unterlage als eigene Zeile. Auch eine begründete
Fehlanzeige zählt als geliefert — sie ist eine Antwort.
</CardDescription>
</div>
{darfSchreiben && (
<Button variant="outline" size="sm" onClick={() => setNeuOffen((o) => !o)}>
<Plus aria-hidden="true" />
{neuOffen ? 'Abbrechen' : 'Nachforderung'}
</Button>
)}
</div>
<Fortschritt
className="pt-2"
wert={fortschritt.geliefert}
gesamt={fortschritt.gesamt}
beschriftung={fortschritt.beschriftung}
/>
</CardHeader>
<CardContent className="space-y-4">
{fortschritt.ueberfaellig.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertTitle>
{fortschritt.ueberfaellig.length}{' '}
{fortschritt.ueberfaellig.length === 1 ? 'Unterlage ist' : 'Unterlagen sind'}{' '}
überfällig
</AlertTitle>
<AlertDescription>
{fortschritt.ueberfaellig.map((z) => z.bezeichnung).join(', ')}. Wenn du sie nicht
beschaffen kannst, melde das dem Träger mit Begründung — Schweigen wird als fehlende
Mitwirkung gewertet (§ 66 SGB I).
</AlertDescription>
</Alert>
)}
{neuOffen && darfSchreiben && (
<div className="rounded-lg border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-semibold">Neue Nachforderung</h3>
<NachforderungFormular
antragId={antragId}
werte={{ ...LEERE_NACHFORDERUNG, reihenfolge: (eintraege.length + 1) * 10 }}
beimSpeichern={() => setNeuOffen(false)}
/>
</div>
)}
{eintraege.length === 0 ? (
<Leerzustand
titel="Noch keine Nachforderungen erfasst"
beschreibung="Übernimm die Standardunterlagen aus der Checkliste — dann steht der übliche Stapel des Bezirks schon hier, bevor er ihn anfordert."
icon={FileText}
/>
) : (
<ul className="divide-y rounded-lg border">
{eintraege.map((eintrag) => (
<TrackerZeile
key={eintrag.id}
antragId={antragId}
eintrag={eintrag}
darfSchreiben={darfSchreiben}
/>
))}
</ul>
)}
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</CardContent>
</Card>
)
}
function TrackerZeile({
antragId,
eintrag,
darfSchreiben,
}: {
antragId: string
eintrag: TrackerEintrag
darfSchreiben: boolean
}) {
const [offen, setOffen] = useState(false)
const [zustand, statusSetzen] = useActionState<Formularzustand, FormData>(
nachforderungStatusSetzen,
{},
)
const ampel = fristAmpel(alsDatum(eintrag.fristBis), eintrag.status)
return (
<li className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{eintrag.bezeichnung}</span>
<NachforderungAbzeichen status={eintrag.status} />
{eintrag.fristBis && <Badge variant={ampel.variante}>{ampel.text}</Badge>}
{eintrag.ausCheckliste && <Badge variant="outline">aus Checkliste</Badge>}
{eintrag.anzahlDokumente > 0 && (
<Badge variant="outline">
<FileText className="size-3" aria-hidden="true" />
{eintrag.anzahlDokumente}
</Badge>
)}
</div>
{eintrag.erlaeuterung && (
<p className="text-sm text-muted-foreground">{eintrag.erlaeuterung}</p>
)}
<p className="text-xs text-muted-foreground">
{eintrag.angefordertAm && <>Angefordert am {formatDatum(eintrag.angefordertAm)}. </>}
{eintrag.fristBis && <>Frist bis {formatDatum(eintrag.fristBis)}. </>}
{eintrag.uebersandtAm && <>Übersandt am {formatDatum(eintrag.uebersandtAm)}.</>}
</p>
{eintrag.status === 'NICHT_VORHANDEN' && eintrag.begruendungNichtVorhanden && (
<p className="rounded-md border border-border bg-muted/50 p-2 text-sm">
<span className="font-medium">Begründung für den Träger: </span>
{eintrag.begruendungNichtVorhanden}
</p>
)}
</div>
{darfSchreiben && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
{eintrag.status === 'OFFEN' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="IN_BESCHAFFUNG"
text="In Beschaffung"
/>
)}
{(eintrag.status === 'OFFEN' || eintrag.status === 'IN_BESCHAFFUNG') && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="UEBERSANDT"
text="Übersandt"
/>
)}
{eintrag.status === 'UEBERSANDT' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="ANERKANNT"
text="Anerkannt"
/>
)}
<Button variant="ghost" size="sm" onClick={() => setOffen((o) => !o)}>
<Pencil aria-hidden="true" />
<span className="sr-only sm:not-sr-only">{offen ? 'Zuklappen' : 'Bearbeiten'}</span>
</Button>
<AktionMitRueckfrage
aktion={nachforderungLoeschen.bind(null, eintrag.id)}
frage={`„${eintrag.bezeichnung}“ in den Papierkorb legen?`}
>
<Trash2 aria-hidden="true" />
<span className="sr-only">Löschen</span>
</AktionMitRueckfrage>
</div>
)}
</div>
<div className="mt-2">
<Meldung zustand={zustand} />
</div>
{offen && darfSchreiben && (
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<NachforderungFormular
antragId={antragId}
werte={{
id: eintrag.id,
bezeichnung: eintrag.bezeichnung,
erlaeuterung: eintrag.erlaeuterung ?? '',
status: eintrag.status,
angefordertAm: eintrag.angefordertAm ?? '',
fristBis: eintrag.fristBis ?? '',
uebersandtAm: eintrag.uebersandtAm ?? '',
begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden ?? '',
reihenfolge: eintrag.reihenfolge,
ausCheckliste: eintrag.ausCheckliste,
}}
beimSpeichern={() => setOffen(false)}
/>
</div>
)}
</li>
)
}
/** Ein-Klick-Statuswechsel für die Fälle, die keine weitere Angabe brauchen. */
function SchnellStatus({
aktion,
nachforderungId,
ziel,
text,
}: {
aktion: (formular: FormData) => void
nachforderungId: string
ziel: NachforderungStatus
text: string
}) {
return (
<form action={aktion}>
<input type="hidden" name="nachforderungId" value={nachforderungId} />
<input type="hidden" name="status" value={ziel} />
<Absenden variant="outline" size="sm" laedtText="…">
{text}
</Absenden>
</form>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState } from 'react'
import type { SozialhilfeBewertung, VermoegensArt, VertragArt } from '@prisma/client'
import { Coins, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { BewertungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import { BewertungFormular } from '@/components/sozialhilfe/bewertung-formular'
import {
ALLE_BEWERTUNGEN,
BEWERTUNG_BESCHRIFTUNG,
hilfenFuerVermoegensart,
hilfenFuerVertragsart,
} from '@/lib/sozialhilfe/schonvermoegen'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Liste der zu bewertenden Posten mit Filter.
*
* Beträge kommen als Number in Cent herüber - BigInt überlebt die Grenze
* zwischen Server und Browser nicht. Angezeigt wird über formatCent(),
* gerechnet wird auf dem Server.
*/
export type ListenPosten = {
id: string
bezeichnung: string
herkunft: 'VERMOEGEN' | 'VERTRAG'
art: string
artBeschriftung: string
bewertung: SozialhilfeBewertung
wertCent: number | null
wertStichtag: string | null
begruendung: string
zusatz: string | null
}
export function Vermoegensliste({
posten,
darfSchreiben,
}: {
posten: ListenPosten[]
darfSchreiben: boolean
}) {
const [filter, setFilter] = useState<SozialhilfeBewertung | 'ALLE'>('ALLE')
const [offen, setOffen] = useState<string | null>(null)
const sichtbar = filter === 'ALLE' ? posten : posten.filter((p) => p.bewertung === filter)
const zaehler = (b: SozialhilfeBewertung) => posten.filter((p) => p.bewertung === b).length
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Bewertung filtern">
<FilterKnopf aktiv={filter === 'ALLE'} onClick={() => setFilter('ALLE')}>
Alle ({posten.length})
</FilterKnopf>
{ALLE_BEWERTUNGEN.map((b) => (
<FilterKnopf key={b} aktiv={filter === b} onClick={() => setFilter(b)}>
{BEWERTUNG_BESCHRIFTUNG[b]} ({zaehler(b)})
</FilterKnopf>
))}
</div>
{sichtbar.length === 0 ? (
<Leerzustand
titel="Keine Posten in dieser Ansicht"
beschreibung={
posten.length === 0
? 'Vermögensposten und Verträge legst du in den Modulen „Vermögen & Kasse“ und „Verträge“ an. Hier bewertest du sie für das Sozialamt.'
: 'Wähle einen anderen Filter, um die übrigen Posten zu sehen.'
}
icon={Coins}
/>
) : (
<ul className="space-y-3">
{sichtbar.map((p) => (
<li key={`${p.herkunft}-${p.id}`}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{p.bezeichnung}</span>
<BewertungAbzeichen bewertung={p.bewertung} />
</div>
<p className="text-sm text-muted-foreground">
{p.artBeschriftung}
{p.zusatz ? ` · ${p.zusatz}` : ''}
</p>
<p className="text-sm">
<span className="font-medium tabular-nums">
{p.wertCent === null ? 'Wert noch nicht ermittelt' : formatCent(p.wertCent)}
</span>
{p.wertStichtag && (
<span className="text-muted-foreground">
{' '}
(Stand {formatDatum(p.wertStichtag)})
</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setOffen((v) => (v === `${p.herkunft}-${p.id}` ? null : `${p.herkunft}-${p.id}`))
}
>
<Pencil aria-hidden="true" />
{offen === `${p.herkunft}-${p.id}` ? 'Zuklappen' : 'Bewerten'}
</Button>
</div>
{p.begruendung && offen !== `${p.herkunft}-${p.id}` && (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{p.begruendung}
</p>
)}
{!p.begruendung && p.bewertung !== 'UNGEPRUEFT' && (
<p className="text-sm text-ampel-orange">
Bewertet, aber ohne Begründung. Im Widerspruchsverfahren zählt nur, was
begründet ist.
</p>
)}
{offen === `${p.herkunft}-${p.id}` && (
<div className="border-t pt-4">
<BewertungFormular
herkunft={p.herkunft}
id={p.id}
bezeichnung={p.bezeichnung}
bewertung={p.bewertung}
begruendung={p.begruendung}
darfSchreiben={darfSchreiben}
hilfen={
p.herkunft === 'VERMOEGEN'
? hilfenFuerVermoegensart(p.art as VermoegensArt)
: hilfenFuerVertragsart(p.art as VertragArt)
}
beimSpeichern={() => setOffen(null)}
/>
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
)
}
function FilterKnopf({
aktiv,
onClick,
children,
}: {
aktiv: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button variant={aktiv ? 'default' : 'outline'} size="sm" onClick={onClick} aria-pressed={aktiv}>
{children}
</Button>
)
}
+155
View File
@@ -0,0 +1,155 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Copy, Loader2, Save } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
/**
* Gemeinsame Formularbausteine der Etappe 6 (Immobilie, Verträge, Pflege).
*
* Sie stehen an einer Stelle, damit Absendeknopf, Fehler- und
* Erfolgsmeldung in allen drei Modulen gleich aussehen und sich gleich
* verhalten. Wer täglich zwischen den Seiten wechselt, soll nicht dreimal
* dieselbe Sache neu lernen.
*/
export function Absendeknopf({
children = 'Speichern',
className,
variant,
}: {
children?: React.ReactNode
className?: string
variant?: 'default' | 'outline' | 'secondary' | 'destructive' | 'ghost'
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className={className} variant={variant}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird gespeichert …
</>
) : (
<>
<Save aria-hidden="true" />
{children}
</>
)}
</Button>
)
}
/** Zeigt Fehler und Erfolg eines Formularzustands - beides mit Text, nie nur farbig. */
export function Zustandsmeldung({ zustand }: { zustand: Formularzustand }) {
if (zustand.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)
}
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)
}
return null
}
/**
* Ein Textbaustein zum Kopieren.
*
* Bewusst als bearbeitbares Textfeld und nicht als fertiges PDF: Jeder
* Fall hat eine Besonderheit, die in den Brief gehört. Ein Text, den man
* nicht mehr ändern kann, wird früher oder später falsch abgeschickt.
*/
export function Textbaustein({
text,
beschriftung,
hoehe = 'min-h-96',
}: {
text: string
beschriftung: string
hoehe?: string
}) {
const [inhalt, setInhalt] = React.useState(text)
const [kopiert, setKopiert] = React.useState(false)
async function kopieren() {
try {
await navigator.clipboard.writeText(inhalt)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 3000)
} catch {
// Ohne Zwischenablage-Erlaubnis bleibt der Text markierbar - das
// genügt, eine Fehlermeldung wäre hier nur im Weg.
setKopiert(false)
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="textbaustein" className="text-sm font-medium">
{beschriftung}
</label>
<Button type="button" variant="outline" size="sm" onClick={kopieren}>
{kopiert ? (
<>
<CheckCircle2 aria-hidden="true" />
Kopiert
</>
) : (
<>
<Copy aria-hidden="true" />
In die Zwischenablage
</>
)}
</Button>
</div>
<textarea
id="textbaustein"
value={inhalt}
onChange={(e) => setInhalt(e.target.value)}
spellCheck
className={cn(
'w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm leading-relaxed',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
hoehe,
)}
/>
<p className="text-sm text-muted-foreground">
Der Text ist ein Entwurf. Lies ihn durch, ergänze die Besonderheiten deines Falls und
schick ihn erst dann ab. Die PDF-Erzeugung kommt in einer späteren Ausbaustufe.
</p>
</div>
)
}
/** Zwei Werte nebeneinander - die Bauform der Stammdatenblätter. */
export function Datenzeile({
beschriftung,
children,
className,
}: {
beschriftung: string
children: React.ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2 py-2', className)}>
<dt className="text-sm text-muted-foreground">{beschriftung}</dt>
<dd className="text-right font-medium tabular-nums">{children}</dd>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
laufzeitJahre: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
laufzeitJahre: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
>
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="laufzeitJahre"
beschriftung="Laufzeit in Jahren"
erklaerung="Um wie viel verlängert sich der Vertrag? Leer lassen für den Regelfall von einem Jahr."
fehler={zustand.felder?.laufzeitJahre}
>
<Input name="laufzeitJahre" inputMode="numeric" defaultValue={werte.laufzeitJahre} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
+213
View File
@@ -0,0 +1,213 @@
import 'server-only'
import {
AlignmentType,
BorderStyle,
Document,
Footer,
Header,
HeadingLevel,
PageNumber,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht'
/**
* DOCX-Ausgabe.
*
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
*
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung.
*
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/
export type DocxKopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({
text,
bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
})
}
function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
]
if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
)
}
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen
}
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 },
}),
new Paragraph({
children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 },
}),
new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}),
]
}
/**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [
new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 },
}),
...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
),
new Paragraph({
children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 },
}),
]
for (const abschnitt of abschnitte) {
if (abschnitt.titel) {
inhalt.push(
new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 },
}),
)
}
for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•')
inhalt.push(
new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 },
}),
)
}
}
inhalt.push(...unterschriftenblock(kopf))
return new Document({
creator: kopf.erstelltVon,
title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true },
),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(' von ', { groesse: 8, grau: true }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 16,
color: '555555',
font: SCHRIFT,
}),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8,
grau: true,
}),
],
}),
],
}),
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text))
}
+417
View File
@@ -0,0 +1,417 @@
import 'server-only'
import type { Betreute, User } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import type { Berichtskopf } from '@/lib/pdf/rahmen'
import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
import { ibanMaskieren } from '@/lib/finanzen/iban'
import { betreuungsjahr, type Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { laufendeBewegungen, type BuchungEingabe, type LaufendeBewegungen } from '@/lib/finanzen/kassenbuch'
import {
rechnungslegungBerechnen,
saldoZumStichtag,
type KontoEingabe,
type Rechnungsdaten,
type Variante,
} from '@/lib/finanzen/rechnungslegung'
import { vermoegenBerechnen, type Vermoegensbild } from '@/lib/finanzen/vermoegen'
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
/**
* Gemeinsamer Unterbau der beiden Finanzberichte.
*
* Hier - und nur hier - wird aus der Datenbank gelesen. Die Rechnungen
* selbst stehen in src/lib/finanzen/ und kommen ohne Datenbank aus; die
* Darstellung steht in den beiden .tsx-Dateien daneben. Diese Trennung
* ist der Grund, warum sich jede Zahl im Bericht als Testfall
* nachrechnen laesst.
*/
export type BerichtsBetreute = {
id: string
name: string
geburtsdatum: Date | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
befreiterBetreuer: boolean
}
export function betreuteFuerBericht(betreute: Betreute): BerichtsBetreute {
return {
id: betreute.id,
name: `${betreute.vorname} ${betreute.nachname}`,
geburtsdatum: betreute.geburtsdatum,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: betreute.bestellungAm,
befreiterBetreuer: betreute.befreiterBetreuer,
}
}
/** Kopfzeile nach dem Vertrag aus docs/SCHNITTSTELLEN.md, Abschnitt 2. */
export function berichtskopf(eingabe: {
titel: string
betreute: BerichtsBetreute
rechtsgrundlage: string
erstelltVon: string
stichtag?: Date | null
zeitraumVon?: Date | null
zeitraumBis?: Date | null
erstelltAm?: Date
}): Berichtskopf {
const behoerde = eingabe.betreute.betreuungsgericht
? eingabe.betreute.aktenzeichenGericht
? `${eingabe.betreute.betreuungsgericht} · Az. ${eingabe.betreute.aktenzeichenGericht}`
: eingabe.betreute.betreuungsgericht
: undefined
return {
titel: eingabe.titel,
betreute: { name: eingabe.betreute.name, geburtsdatum: eingabe.betreute.geburtsdatum },
behoerde,
aktenzeichen: eingabe.betreute.aktenzeichenGericht ?? undefined,
zeitraumVon: eingabe.zeitraumVon ?? null,
zeitraumBis: eingabe.zeitraumBis ?? null,
stichtag: eingabe.stichtag ?? null,
rechtsgrundlage: eingabe.rechtsgrundlage,
erstelltAm: eingabe.erstelltAm ?? new Date(),
erstelltVon: eingabe.erstelltVon,
}
}
// -----------------------------------------------------------------------------
// Laden
// -----------------------------------------------------------------------------
/**
* Alle Buchungen einer betreuten Person, entschluesselt und um die
* Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel,
* Betreuerkonto ja/nein).
*/
export async function buchungenLaden(
betreuteId: string,
zeitraum?: { von: Date; bis: Date },
): Promise<BuchungEingabe[]> {
const zeilen = await db.buchung.findMany({
where: {
betreuteId,
...nichtGeloescht,
...(zeitraum
? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } }
: {}),
},
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }],
})
return 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,
}
})
}
/** Konten mit ihrer vollstaendigen Saldostand-Historie. */
export async function kontenLaden(betreuteId: string): Promise<KontoEingabe[]> {
const zeilen = await db.konto.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { saldostaende: { orderBy: { stichtag: 'asc' } } },
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
})
return zeilen.map((konto) => {
const offen = oeffnen('Konto', konto)
return {
id: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
art: konto.art,
istBetreuerkonto: konto.istBetreuerkonto,
ibanMaskiert: offen.iban ? ibanMaskieren(offen.iban) : null,
saldostaende: konto.saldostaende.map((s) => ({
stichtag: s.stichtag,
betragCent: s.betragCent,
quelle: s.quelle,
})),
}
})
}
export type KontostandZeile = {
kontoId: string
bezeichnung: string
bank: string | null
artBeschriftung: string
istBetreuerkonto: boolean
ibanMaskiert: string | null
betragCent: bigint | null
stichtag: Date | null
quelle: string | null
}
/** Kontostaende zu einem Stichtag - fuer Uebersicht und Verzeichnis. */
export function kontostaendeZumStichtag(
konten: KontoEingabe[],
stichtag: Date,
): KontostandZeile[] {
return konten.map((konto) => {
const saldo = saldoZumStichtag(konto.saldostaende, stichtag)
return {
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
istBetreuerkonto: konto.istBetreuerkonto,
ibanMaskiert: konto.ibanMaskiert ?? null,
betragCent: saldo?.betragCent ?? null,
stichtag: saldo ? alsTag(saldo.stichtag) : null,
quelle: saldo?.quelle ?? null,
}
})
}
/** Vermoegensposten mit ihrer Wert-Historie, gerechnet auf einen Stichtag. */
export async function vermoegenLaden(
betreuteId: string,
stichtag: Date,
): Promise<Vermoegensbild> {
const posten = await db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: {
wertstaende: {
orderBy: { stichtag: 'asc' },
include: { nachweisDokument: { select: { id: true, titel: true } } },
},
},
})
return vermoegenBerechnen(
posten.map((p) => ({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
kontoId: p.kontoId,
immobilieId: p.immobilieId,
vertragId: p.vertragId,
bewertungSozialhilfe: p.bewertungSozialhilfe,
wertstaende: p.wertstaende.map((w) => ({
stichtag: w.stichtag,
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
nachweisTitel: w.nachweisDokument?.titel ?? null,
bemerkung: w.bemerkung,
})),
})),
stichtag,
)
}
// -----------------------------------------------------------------------------
// Vermoegensverzeichnis (§ 1835 BGB)
// -----------------------------------------------------------------------------
export type VermoegensverzeichnisDaten = {
kopf: Berichtskopf
betreute: BerichtsBetreute
stichtag: Date
/** Konten mit ihrem Stand am Stichtag - ohne Konten des Betreuers. */
kontostaende: KontostandZeile[]
summeKontenCent: bigint
/** Uebrige Vermoegensposten. Posten der Art KONTO bleiben aussen vor. */
vermoegen: Vermoegensbild
summeAktivaCent: bigint
summePassivaCent: bigint
reinvermoegenCent: bigint
laufend: LaufendeBewegungen
hinweise: string[]
}
/**
* Konten erscheinen im Verzeichnis mit ihrem Saldostand, nicht als
* Vermoegensposten. Deshalb werden Posten der Art KONTO uebersprungen -
* sonst stuende dasselbe Guthaben zweimal in der Summe.
*/
export async function vermoegensverzeichnisDaten(eingabe: {
betreute: Betreute
stichtag: Date
erstelltVon: string
monateRueckblick?: number
}): Promise<VermoegensverzeichnisDaten> {
const stichtag = alsTag(eingabe.stichtag)
const monate = eingabe.monateRueckblick ?? 12
const betreute = betreuteFuerBericht(eingabe.betreute)
const [konten, alleBuchungen, vermoegenAlle] = await Promise.all([
kontenLaden(betreute.id),
buchungenLaden(betreute.id),
vermoegenLaden(betreute.id, stichtag),
])
const kontostaende = kontostaendeZumStichtag(
konten.filter((k) => !k.istBetreuerkonto),
stichtag,
)
const summeKontenCent = kontostaende.reduce((s, z) => s + (z.betragCent ?? 0n), 0n)
// Posten der Art KONTO werden herausgenommen - siehe Kommentar oben.
const vermoegen: Vermoegensbild = {
...vermoegenAlle,
aktivaGruppen: vermoegenAlle.aktivaGruppen.filter((g) => g.art !== 'KONTO'),
aktiva: vermoegenAlle.aktiva.filter((p) => p.art !== 'KONTO'),
ohneWert: vermoegenAlle.ohneWert.filter((p) => p.art !== 'KONTO'),
summeAktivaCent: vermoegenAlle.aktiva
.filter((p) => p.art !== 'KONTO')
.reduce((s, p) => s + p.wertCent, 0n),
}
const von = tagePlus(monatePlus(stichtag, -monate), 1)
const laufend = laufendeBewegungen(alleBuchungen, von, stichtag, monate)
const summeAktivaCent = summeKontenCent + vermoegen.summeAktivaCent
const summePassivaCent = vermoegen.summePassivaCent
const hinweise: string[] = []
for (const zeile of kontostaende) {
if (zeile.betragCent === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ ist zum Stichtag kein Saldostand erfasst. Das Konto steht ` +
'ohne Betrag im Verzeichnis.',
)
} else if (zeile.stichtag && zeile.stichtag.getTime() !== stichtag.getTime()) {
hinweise.push(
`Der Stand von „${zeile.bezeichnung}“ stammt vom ${kurz(zeile.stichtag)}, nicht vom Stichtag.`,
)
}
}
for (const posten of vermoegen.ohneWert) {
hinweise.push(
`Für „${posten.bezeichnung}“ liegt zum Stichtag keine Bewertung vor. ` +
(posten.spaetereWertstaende > 0
? 'Es gibt nur Wertstände nach dem Stichtag – die dürfen hier nicht verwendet werden.'
: 'Es ist noch kein Wertstand erfasst.'),
)
}
if (vermoegen.aeltesterWertTage !== null && vermoegen.aeltesterWertTage > 365) {
hinweise.push(
`Der älteste verwendete Wert ist ${vermoegen.aeltesterWertTage} Tage alt. ` +
'Vor der Abgabe lohnt sich eine aktuelle Bewertung.',
)
}
return {
kopf: berichtskopf({
titel: 'Vermögensverzeichnis',
betreute,
rechtsgrundlage: '§ 1835 BGB',
erstelltVon: eingabe.erstelltVon,
stichtag,
}),
betreute,
stichtag,
kontostaende,
summeKontenCent,
vermoegen,
summeAktivaCent,
summePassivaCent,
reinvermoegenCent: summeAktivaCent - summePassivaCent,
laufend,
hinweise,
}
}
// -----------------------------------------------------------------------------
// Rechnungslegung (§§ 1859, 1865 BGB)
// -----------------------------------------------------------------------------
export type RechnungslegungDaten = {
kopf: Berichtskopf
betreute: BerichtsBetreute
jahr: Betreuungsjahr
rechnung: Rechnungsdaten
/** Vermoegen am Ende des Zeitraums - gehoert in beide Varianten. */
vermoegenEnde: Vermoegensbild
}
export async function rechnungslegungDaten(eingabe: {
betreute: Betreute
jahrNummer: number
variante: Variante
erstelltVon: string
}): Promise<RechnungslegungDaten> {
if (!eingabe.betreute.bestellungAm) {
throw new Error(
'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. ' +
'Bitte zuerst in den Stammdaten das Datum der Bestellung eintragen.',
)
}
const betreute = betreuteFuerBericht(eingabe.betreute)
const jahr = betreuungsjahr(eingabe.betreute.bestellungAm, eingabe.jahrNummer)
const [konten, buchungen, vermoegenEnde] = await Promise.all([
kontenLaden(betreute.id),
buchungenLaden(betreute.id, { von: jahr.von, bis: jahr.bis }),
vermoegenLaden(betreute.id, jahr.bis),
])
const rechnung = rechnungslegungBerechnen({
von: jahr.von,
bis: jahr.bis,
variante: eingabe.variante,
konten,
buchungen,
betreuungsjahr: jahr,
heute: heute(),
})
const titel =
eingabe.variante === 'UEBERSICHT_1859'
? 'Vermögensübersicht'
: 'Rechnungslegung'
return {
kopf: berichtskopf({
titel: `${titel} – ${jahr.nummer}. Betreuungsjahr`,
betreute,
rechtsgrundlage: eingabe.variante === 'UEBERSICHT_1859' ? '§ 1859 BGB' : '§ 1865 BGB',
erstelltVon: eingabe.erstelltVon,
zeitraumVon: jahr.von,
zeitraumBis: jahr.bis,
}),
betreute,
jahr,
rechnung,
vermoegenEnde,
}
}
/** Der Name, der unter dem Bericht steht. */
export function erstellerName(benutzer: User): string {
return benutzer.name
}
function kurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
+694
View File
@@ -0,0 +1,694 @@
import type {
Aufgabenkreis,
GesundheitsArt,
KontaktArt,
VorgangKategorie,
VorgangStatus,
} from '@prisma/client'
import { formatDatum } from '@/lib/utils'
/**
* Jahresbericht nach § 1863 BGB - Textentwurf.
*
* Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
* zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
* und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
* daraus wird, entscheidet der Betreuer: Der Text ist im Browser
* bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
*
* Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
* dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
* Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
* wurden, tauchen gar nicht auf.
*
* Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
* Daten, damit die Textbausteine testbar bleiben.
*/
// -----------------------------------------------------------------------------
// Eingangsdaten
// -----------------------------------------------------------------------------
export type JahresberichtBetreute = {
anrede: string | null
vorname: string
nachname: string
geburtsdatum: Date | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimEinzugAm: Date | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
pflegegrad: number | null
pflegegradSeit: Date | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
/** Klartext aus diagnosenVerschluesselt. */
diagnosen: string | null
/** Klartext aus ermittelterWilleVerschluesselt. */
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
aufgabenkreise: Aufgabenkreis[]
aufgabenkreiseFreitext: string | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
bestellungBis: Date | null
}
export type JahresberichtKontakt = {
datum: Date
art: KontaktArt
mitBetreuter: boolean
dauerMinuten: number | null
gespraechspartner: string | null
/** Klartext aus wuenscheVerschluesselt. */
wuensche: string | null
}
export type JahresberichtGesundheit = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
titel: string
einrichtung: string | null
abwesenheitstage: number | null
}
export type JahresberichtVorgang = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
naechsterSchritt: string | null
gegenstelle: string | null
genehmigungspflichtig: boolean
erledigtAm: Date | null
}
export type JahresberichtQuellen = {
betreute: JahresberichtBetreute
zeitraumVon: Date
zeitraumBis: Date
kontakte: JahresberichtKontakt[]
gesundheit: JahresberichtGesundheit[]
vorgaenge: JahresberichtVorgang[]
}
export type Berichtsabschnitt = {
nummer: number
titel: string
rechtsgrundlage: string
absaetze: string[]
}
// -----------------------------------------------------------------------------
// Beschriftungen
// -----------------------------------------------------------------------------
/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
export const ERGAENZEN = '[bitte ergänzen]'
export const ENTWURFSHINWEIS =
'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
export const KONTAKTART_EINZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuche',
KRANKENHAUS: 'Krankenhausaufenthalte',
EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
STURZ: 'Stürze',
MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
INHALATION: 'Inhalationsbehandlungen',
HILFSMITTEL: 'Hilfsmittelversorgungen',
IMPFUNG: 'Impfungen',
REHA: 'Rehabilitationsmaßnahmen',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
HEILMITTEL: 'Heilmittelbehandlungen',
SONSTIGES: 'sonstige gesundheitliche Ereignisse',
}
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
IMMOBILIE: 'Immobilie und Wohnraum',
VERSICHERUNG: 'Versicherungen',
HEIM_PFLEGE: 'Heim und Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const AUFGABENKREIS_BESCHRIFTUNG: Record<Aufgabenkreis, string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'weitere Aufgaben',
}
export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
{ nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
{
nummer: 2,
titel: 'Persönlicher Kontakt zur betreuten Person',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
},
{
nummer: 3,
titel: 'Wünsche der betreuten Person und ihre Umsetzung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
},
{
nummer: 4,
titel: 'Erledigte und laufende Angelegenheiten',
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
},
{
nummer: 5,
titel: 'Einschätzung zur Fortführung der Betreuung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
},
]
// -----------------------------------------------------------------------------
// Sprachliche Helfer
// -----------------------------------------------------------------------------
/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
export function aufzaehlung(teile: readonly string[]): string {
const sauber = teile.filter((t) => t.trim() !== '')
if (sauber.length === 0) return ''
if (sauber.length === 1) return sauber[0]!
return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
}
/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
export function malAngabe(anzahl: number): string {
const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
return woerter[anzahl] ?? `${anzahl}-mal`
}
/** 95 Minuten -> "1 Stunde und 35 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
const teile: string[] = []
if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
return aufzaehlung(teile)
}
/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
export function vollerName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
}
/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
export function kurzName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
}
/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
function pronomen(betreute: Pick<JahresberichtBetreute, 'anrede'>): { nominativ: string; possessiv: string } {
const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
}
function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
return zeile.trim() === '' ? null : zeile
}
function tageZwischen(von: Date, bis: Date): number {
return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
}
function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
}
function haeufigkeit<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
return karte
}
// -----------------------------------------------------------------------------
// Die fuenf Abschnitte
// -----------------------------------------------------------------------------
function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const pron = pronomen(betreute)
const absaetze: string[] = []
// --- Aufenthalt ---
const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
if (betreute.heimName) {
const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
`in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
`im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
)
} else {
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
`liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
`Berichtszeitraum gelebt hat.`,
)
}
const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
if (frueher) {
absaetze.push(
`Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
`Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
)
}
// --- Pflege und Behinderung ---
const pflegesaetze: string[] = []
if (betreute.pflegegrad !== null) {
const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
} else {
pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
}
if (betreute.schwerbehinderungGdB !== null) {
const merk = betreute.schwerbehinderungMerkzeichen
? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
: ''
pflegesaetze.push(
`Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
)
}
absaetze.push(pflegesaetze.join(' '))
// --- Gesundheitszustand im Berichtszeitraum ---
const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
const gesundheitssaetze: string[] = []
if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
gesundheitssaetze.push(
`Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
)
}
if (ereignisse.length === 0) {
gesundheitssaetze.push(
`Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
`keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
)
} else {
const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
const aufzaehlungTeile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
gesundheitssaetze.push(
`Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
`${aufzaehlung(aufzaehlungTeile)}.`,
)
const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
if (klinik.length > 0) {
const beschreibungen = klinik.map((e) => {
const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
})
gesundheitssaetze.push(
`Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
)
}
const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
if (abwesend > 0) {
gesundheitssaetze.push(
`Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
)
}
}
absaetze.push(gesundheitssaetze.join(' '))
// --- Vorsorge ---
const vorsorge: string[] = []
vorsorge.push(
betreute.patientenverfuegungVorhanden
? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
: 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
)
vorsorge.push(
betreute.vorsorgevollmachtVorhanden
? 'Eine Vorsorgevollmacht ist vorhanden.'
: 'Eine Vorsorgevollmacht ist nicht vorhanden.',
)
absaetze.push(vorsorge.join(' '))
return absaetze
}
function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
const mitIhr = imFenster
.filter((k) => k.mitBetreuter)
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
absaetze.push(
`Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
`${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
)
if (mitIhr.length === 0) {
absaetze.push(
`Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
`nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
`persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
`nach — ${ERGAENZEN}.`,
)
} else {
const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1
? `ein ${KONTAKTART_EINZAHL[art]}`
: `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
absaetze.push(
`Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
`${aufzaehlung(teile)}.`,
)
const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
const luecken = mitIhr
.slice(1)
.map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
if (groessteLuecke > 0) {
rhythmus.push(
`Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
)
}
rhythmus.push(
`Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
`der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
)
absaetze.push(rhythmus.join(' '))
const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
if (minuten > 0) {
absaetze.push(
`Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
`wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
`das Befinden und die persönliche Lage von ${name}.`,
)
}
}
if (mitDritten.length > 0) {
const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
absaetze.push(
`Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
`${aufzaehlung(teile)}.${wer}`,
)
}
return absaetze
}
function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
absaetze.push(
`Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
`${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
`festgehalten.`,
)
const mitWunsch = quellen.kontakte
.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
.filter((k) => (k.wuensche ?? '').trim() !== '')
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
if (mitWunsch.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
`${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
`Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
)
} else {
absaetze.push(
`Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
)
for (const kontakt of mitWunsch) {
absaetze.push(
`• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
`${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
)
}
absaetze.push(
`Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
`entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
)
}
if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
absaetze.push(
`Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
`${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
)
}
return absaetze
}
function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
const { zeitraumVon, zeitraumBis } = quellen
const absaetze: string[] = []
if (quellen.vorgaenge.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
`hast du bearbeitet?`,
)
return absaetze
}
const erledigt = quellen.vorgaenge.filter(
(v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
absaetze.push(
`Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
`${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
)
const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
)
for (const kategorie of kategorien) {
const fertig = erledigt.filter((v) => v.kategorie === kategorie)
const offen = laufend.filter((v) => v.kategorie === kategorie)
const saetze: string[] = []
if (fertig.length > 0) {
const titel = fertig.map((v) =>
v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
)
saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
}
if (offen.length > 0) {
const titel = offen.map((v) => {
const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
return `${v.titel}${wer}${schritt}`
})
saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
}
absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
}
const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
if (genehmigungspflichtig.length > 0) {
absaetze.push(
`Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
`Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
)
}
return absaetze
}
function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
const { betreute } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
kreise.push(betreute.aufgabenkreiseFreitext.trim())
}
absaetze.push(
kreise.length > 0
? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
: `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
const begruendung =
laufend.length > 0
? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
`${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
`ist daher nicht erkennbar.`
: `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
absaetze.push(
`Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
`fortzuführen. ${begruendung}`,
)
absaetze.push(
`Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
`einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
`${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
`einzuschränken oder aufzuheben.`,
)
absaetze.push(
`${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
`falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
)
return absaetze
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
* gleicher Text - damit sich der Entwurf testen laesst.
*/
export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
const bauer = [
abschnittPersoenlicheVerhaeltnisse,
abschnittKontakt,
abschnittWuensche,
abschnittAngelegenheiten,
abschnittEinschaetzung,
]
return GLIEDERUNG.map((eintrag, i) => ({
nummer: eintrag.nummer,
titel: eintrag.titel,
rechtsgrundlage: eintrag.rechtsgrundlage,
absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
}))
}
/**
* Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
* abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
* damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
*/
export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
return abschnitte
.map((abschnitt) =>
[`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
)
.join('\n\n')
.trim()
}
/** Direktweg von den Quellen zum Text. */
export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
}
/**
* Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
* ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
* nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
* auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
*/
export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
const zeilen = text.replace(/\r\n/g, '\n').split('\n')
const abschnitte: { titel: string | null; absaetze: string[] }[] = []
let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
for (const zeile of zeilen) {
if (/^\s*\d+\.\s+\S/.test(zeile)) {
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
aktuell = { titel: zeile.trim(), absaetze: [] }
continue
}
if (zeile.trim() === '') continue
aktuell.absaetze.push(zeile.trim())
}
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
return abschnitte
}
/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
export function offeneErgaenzungen(text: string): number {
return text.split(ERGAENZEN).length - 1
}
+234
View File
@@ -0,0 +1,234 @@
import 'server-only'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
// Die Endung steht hier absichtlich: Neben dieser Datei liegt
// jahresbericht.ts mit den reinen Textbausteinen. Ein Import ohne Endung
// waere mehrdeutig - Webpack probiert ".tsx" vor ".ts" und wuerde diese
// Datei sich selbst importieren, TypeScript und Vitest wuerden die ".ts"
// nehmen. Mit ausgeschriebener Endung greifen alle drei dieselbe Datei.
// Damit trotzdem jeder Aufrufer alles unter einem Namen findet, wird die
// reine Schnittstelle unten vollstaendig weitergereicht.
import {
jahresberichtEntwurfText,
textZuAbschnitten,
type JahresberichtQuellen,
} from './jahresbericht.ts'
export * from './jahresbericht.ts'
/**
* Jahresbericht - Datenbeschaffung und PDF.
*
* Die Textbausteine stehen in jahresbericht.ts und kommen ohne Datenbank
* aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins PDF geht,
* ist IMMER der gespeicherte, vom Betreuer ueberarbeitete Entwurfstext -
* nie ein frisch berechneter. Sonst stuende im abgegebenen Bericht etwas
* anderes als das, was auf dem Bildschirm zur Freigabe stand.
*/
// -----------------------------------------------------------------------------
// Quelldaten aus der Datenbank
// -----------------------------------------------------------------------------
/**
* Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
* Zugriffsberechtigung bereits geprueft (erfordereZugang) - diese Funktion
* fragt nicht noch einmal, sie eng auf die betreuteId zu begrenzen ist
* aber Pflicht.
*/
export async function jahresberichtQuellenLaden(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<JahresberichtQuellen> {
const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
db.kontaktprotokoll.findMany({
where: {
betreuteId,
datum: { gte: zeitraumVon, lte: zeitraumBis },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
}),
db.gesundheitsereignis.findMany({
where: {
betreuteId,
datum: { gte: zeitraumVon, lte: zeitraumBis },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
}),
db.vorgang.findMany({
where: {
betreuteId,
...nichtGeloescht,
// Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
// Bericht des Vorjahres - hier waere es nur Ballast.
OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
},
orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
}),
])
const betreute = oeffnen('Betreute', roheBetreute)
return {
betreute: {
anrede: betreute.anrede,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsdatum: betreute.geburtsdatum,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimEinzugAm: betreute.heimEinzugAm,
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: betreute.pflegegradSeit,
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: betreute.diagnosen,
ermittelterWille: betreute.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: betreute.bestellungAm,
bestellungBis: betreute.bestellungBis,
},
zeitraumVon,
zeitraumBis,
kontakte: kontakte.map((k) => {
const offen = oeffnen('Kontaktprotokoll', k)
return {
datum: k.datum,
art: k.art,
mitBetreuter: k.mitBetreuter,
dauerMinuten: k.dauerMinuten,
gespraechspartner: k.gespraechspartner,
wuensche: offen.wuensche,
}
}),
gesundheit: gesundheit.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
art: e.art,
titel: e.titel,
einrichtung: e.einrichtung,
abwesenheitstage: e.abwesenheitstage,
})),
vorgaenge: vorgaenge.map((v) => ({
titel: v.titel,
kategorie: v.kategorie,
status: v.status,
naechsterSchritt: v.naechsterSchritt,
gegenstelle: v.gegenstelle,
genehmigungspflichtig: v.genehmigungspflichtig,
erledigtAm: v.erledigtAm,
})),
}
}
/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu berechnen". */
export async function jahresberichtEntwurfAusDatenbank(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<string> {
return jahresberichtEntwurfText(
await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
)
}
// -----------------------------------------------------------------------------
// PDF
// -----------------------------------------------------------------------------
const stile = StyleSheet.create({
ueberschrift: {
fontSize: 12,
fontFamily: 'Helvetica-Bold',
marginTop: 14,
marginBottom: 4,
},
absatz: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 6,
textAlign: 'justify',
},
aufzaehlung: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 3,
marginLeft: 12,
},
vorspann: {
fontSize: 9,
lineHeight: 1.4,
marginBottom: 10,
color: '#444444',
},
})
/**
* Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
* fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
* Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
* das sich im Textfeld auch von Hand fortschreiben laesst.
*/
export function JahresberichtInhalt({ text }: { text: string }) {
const abschnitte = textZuAbschnitten(text)
return (
<View>
{abschnitte.map((abschnitt, i) => (
<View key={`abschnitt-${i}`} wrap={true}>
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
{abschnitt.absaetze.map((absatz, j) => (
<Text
key={`absatz-${i}-${j}`}
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
>
{absatz}
</Text>
))}
</View>
))}
</View>
)
}
export function JahresberichtDokument({
kopf,
text,
vorspann,
}: {
kopf: Berichtskopf
text: string
vorspann?: string
}) {
return (
<Document title={kopf.titel} author={kopf.erstelltVon} language="de">
<Berichtsseite kopf={kopf} mitUnterschrift={true}>
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
<JahresberichtInhalt text={text} />
</Berichtsseite>
</Document>
)
}
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
return pdfErzeugen(<JahresberichtDokument kopf={kopf} text={text} />)
}
+507
View File
@@ -0,0 +1,507 @@
import 'server-only'
import * as React from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { formatCent, formatDatum } from '@/lib/utils'
import { RICHTUNG_BESCHRIFTUNG, VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { VARIANTE_RECHTSGRUNDLAGE } from '@/lib/finanzen/rechnungslegung'
import type { RechnungslegungDaten } from '@/lib/berichte/gemeinsam'
/**
* Vermoegensuebersicht (§ 1859 BGB) und Rechnungslegung (§ 1865 BGB).
*
* Ein Dokument, zwei Auspraegungen:
*
* - Befreite Betreuer - Ehegatten, Lebenspartner, Eltern, Kinder und
* Geschwister - schulden nach § 1859 BGB nur eine Uebersicht ueber den
* Bestand des Vermoegens. Das ist hier der Regelfall.
* - Alle uebrigen Betreuer legen nach § 1865 BGB vollstaendig Rechnung:
* jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.
*
* Beide Varianten enthalten dieselbe Rechenprobe, und beide weisen eine
* Abweichung offen aus. Der Gedanke dahinter: Ein Bericht, in dem eine
* Differenz stillschweigend verschwindet, ist eine falsche Erklaerung
* gegenueber dem Gericht - eine, die man selbst unterschreibt. Ein
* Bericht, der die Differenz benennt, ist eine ehrliche Zwischenmeldung.
*/
const stile = StyleSheet.create({
abschnitt: { marginTop: 14 },
ueberschrift: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#333333',
},
untertitel: { fontSize: 9.5, fontFamily: 'Helvetica-Bold', marginTop: 8, marginBottom: 3 },
zeile: {
flexDirection: 'row',
paddingVertical: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#dddddd',
},
kopfzeile: {
flexDirection: 'row',
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#999999',
},
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
text: { fontSize: 9 },
klein: { fontSize: 7.5, color: '#555555' },
nummer: { width: 26, fontSize: 8 },
datum: { width: 52, fontSize: 8 },
breit: { flex: 1, paddingRight: 6 },
mittel: { width: 110, paddingRight: 6 },
schmal: { width: 46, paddingRight: 4 },
betrag: { width: 78, textAlign: 'right', fontSize: 9 },
summenzeile: {
flexDirection: 'row',
paddingTop: 4,
marginTop: 2,
borderTopWidth: 1,
borderTopColor: '#333333',
},
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
summenbetrag: { width: 78, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
probe: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#333333',
},
probeStimmt: { borderColor: '#15803d', backgroundColor: '#f0fdf4' },
probeFehlt: { borderColor: '#b91c1c', backgroundColor: '#fef2f2' },
probezeile: { flexDirection: 'row', paddingVertical: 2 },
probetext: { flex: 1, fontSize: 9 },
probebetrag: { width: 90, textAlign: 'right', fontSize: 9 },
hinweisblock: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#b45309',
backgroundColor: '#fffbeb',
},
hinweistext: { fontSize: 8, marginBottom: 3 },
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
fett: { fontFamily: 'Helvetica-Bold' },
})
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
return (
<View style={stile.summenzeile}>
<Text style={stile.summentext}>{text}</Text>
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
</View>
)
}
export function RechnungslegungDokument({
daten,
}: {
daten: RechnungslegungDaten
}): React.ReactElement {
const { rechnung, jahr } = daten
const voll = rechnung.variante === 'VOLL_1865'
const grundlage = VARIANTE_RECHTSGRUNDLAGE[rechnung.variante]
return (
<Document
title={`${daten.kopf.titel} ${daten.betreute.name}`}
author={daten.kopf.erstelltVon}
subject={`${daten.kopf.titel} nach ${grundlage}`}
>
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
<Text style={stile.erklaerung}>
{voll
? 'Vollständige Rechnungslegung nach § 1865 BGB über die Verwaltung des Vermögens der ' +
'betreuten Person. Die Rechnung weist Anfangsbestand, sämtliche Einnahmen und ' +
'Ausgaben sowie den Endbestand aus; die Belege sind fortlaufend nummeriert und ' +
'werden in dieser Reihenfolge beigefügt.'
: 'Vermögensübersicht nach § 1859 BGB. Als befreiter Betreuer ist über den Bestand ' +
'des Vermögens und dessen Veränderung im Betreuungsjahr zu berichten; eine ' +
'vollständige Rechnungslegung mit Einzelbelegen nach § 1865 BGB ist nicht ' +
'geschuldet. Die Belege werden gleichwohl aufbewahrt und können vorgelegt werden.'}
</Text>
<Text style={stile.erklaerung}>
Berichtszeitraum: {jahr.bezeichnung}. Das Betreuungsjahr läuft ab dem Tag der Bestellung
{daten.betreute.bestellungAm
? ` (${formatDatum(daten.betreute.bestellungAm)})`
: ''}
, nicht ab dem Kalenderjahr.
</Text>
{rechnung.zwischenaufstellung && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Zwischenaufstellung</Text>
<Text style={stile.hinweistext}>
Das Betreuungsjahr ist noch nicht abgelaufen. Diese Aufstellung gibt den Stand zum
Zeitpunkt der Erstellung wieder und ersetzt die Rechnungslegung nach Ablauf des
Jahres nicht.
</Text>
</View>
)}
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>A. Bestände</Text>
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.breit]}>Konto</Text>
<Text style={[stile.kopftext, stile.mittel]}>Bank / IBAN</Text>
<Text style={[stile.kopftext, stile.betrag]}>Anfang</Text>
<Text style={[stile.kopftext, stile.betrag]}>Ende</Text>
</View>
{rechnung.bestaende.length === 0 ? (
<Text style={stile.text}>Keine Konten erfasst.</Text>
) : (
rechnung.bestaende.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{konto.bezeichnung}</Text>
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
</View>
<View style={stile.betrag}>
<Text style={stile.text}>{formatCent(konto.anfangCent)}</Text>
<Text style={stile.klein}>{formatDatum(konto.anfangStichtag)}</Text>
</View>
<View style={stile.betrag}>
<Text style={stile.text}>{formatCent(konto.endeCent)}</Text>
<Text style={stile.klein}>{formatDatum(konto.endeStichtag)}</Text>
</View>
</View>
))
)}
<Summenzeile
text={`Anfangsbestand am ${formatDatum(rechnung.von)}`}
betragCent={rechnung.anfangsbestandCent}
/>
<Summenzeile
text={`Endbestand am ${formatDatum(rechnung.bis)}`}
betragCent={rechnung.endbestandCent}
/>
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>B. Einnahmen</Text>
{rechnung.einnahmen.length === 0 ? (
<Text style={stile.text}>Im Berichtszeitraum sind keine Einnahmen erfasst.</Text>
) : (
rechnung.einnahmen.map((gruppe) => (
<View key={gruppe.kategorie} wrap={false}>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
{gruppe.beschriftung}
</Text>
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
{formatCent(gruppe.summeCent)}
</Text>
</View>
{voll &&
gruppe.buchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
{buchung.verwendungszweck && (
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
)}
</View>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
))
)}
<Summenzeile text="Summe Einnahmen" betragCent={rechnung.summeEinnahmenCent} />
{rechnung.vermoegenswirksamEinnahmenCent !== 0n && (
<Text style={stile.klein}>
davon vermögenswirksam (kein laufendes Einkommen):{' '}
{formatCent(rechnung.vermoegenswirksamEinnahmenCent)}
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>C. Ausgaben</Text>
{rechnung.ausgaben.length === 0 ? (
<Text style={stile.text}>Im Berichtszeitraum sind keine Ausgaben erfasst.</Text>
) : (
rechnung.ausgaben.map((gruppe) => (
<View key={gruppe.kategorie} wrap={false}>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
{gruppe.beschriftung}
</Text>
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
{formatCent(gruppe.summeCent)}
</Text>
</View>
{voll &&
gruppe.buchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
{buchung.verwendungszweck && (
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
)}
</View>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
))
)}
<Summenzeile text="Summe Ausgaben" betragCent={rechnung.summeAusgabenCent} />
{rechnung.vermoegenswirksamAusgabenCent !== 0n && (
<Text style={stile.klein}>
davon vermögenswirksam: {formatCent(rechnung.vermoegenswirksamAusgabenCent)}
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
<View
style={[stile.probe, rechnung.probe.stimmt ? stile.probeStimmt : stile.probeFehlt]}
wrap={false}
>
<Text style={[stile.probetext, stile.fett, { marginBottom: 4 }]}>D. Rechenprobe</Text>
<View style={stile.probezeile}>
<Text style={stile.probetext}>Anfangsbestand</Text>
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.anfangsbestandCent)}</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>zuzüglich Einnahmen</Text>
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.einnahmenCent)}</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>abzüglich Ausgaben</Text>
<Text style={stile.probebetrag}>{formatCent(-rechnung.probe.ausgabenCent)}</Text>
</View>
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
<Text style={[stile.probetext, stile.fett]}>rechnerischer Endbestand</Text>
<Text style={[stile.probebetrag, stile.fett]}>
{formatCent(rechnung.probe.rechnerischerEndbestandCent)}
</Text>
</View>
<View style={stile.probezeile}>
<Text style={stile.probetext}>Endbestand laut Saldoständen</Text>
<Text style={stile.probebetrag}>
{formatCent(rechnung.probe.gebuchterEndbestandCent)}
</Text>
</View>
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
<Text style={[stile.probetext, stile.fett]}>
{rechnung.probe.stimmt ? 'Differenz – die Rechnung geht auf' : 'Differenz'}
</Text>
<Text style={[stile.probebetrag, stile.fett]}>
{formatCent(rechnung.probe.differenzCent)}
</Text>
</View>
{!rechnung.probe.stimmt && (
<Text style={[stile.hinweistext, { marginTop: 5 }]}>
Die Rechenprobe geht nicht auf. Die Differenz ist hier ausgewiesen und nicht
ausgeglichen worden. Sie bedeutet, dass entweder Buchungen im Kassenbuch fehlen oder
ein Saldostand nicht erfasst ist. Vor der Abgabe an das Gericht ist die Ursache zu
klären.
</Text>
)}
</View>
{/* ------------------------------------------------------------- */}
{rechnung.umbuchungen.length > 0 && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>E. Umbuchungen zwischen eigenen Konten</Text>
<Text style={stile.erklaerung}>
Diese Vorgänge verschieben Geld zwischen Konten der betreuten Person. Sie sind weder
Einnahme noch Ausgabe und daher in keiner Summe oben enthalten; sie stehen hier nur
der Vollständigkeit halber.
</Text>
{rechnung.umbuchungen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<Text style={[stile.text, stile.breit]}>
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Umbuchung'}
</Text>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
</View>
)}
{rechnung.eigeneAuslagen.length > 0 && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>F. Eigene Auslagen des Betreuers</Text>
<Text style={stile.erklaerung}>
Aufwendungen, die der Betreuer aus eigenen Mitteln getragen hat. Sie gehören nicht
zum Vermögen der betreuten Person und sind deshalb in den Summen oben nicht
enthalten. Ihre Erstattung richtet sich nach §§ 1877, 1878 BGB.
</Text>
{rechnung.eigeneAuslagen.map((buchung) => (
<View key={buchung.id} style={stile.zeile}>
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
<Text style={[stile.text, stile.breit]}>
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Auslage'}
</Text>
<Text style={[stile.klein, stile.schmal]}>
{RICHTUNG_BESCHRIFTUNG[buchung.richtung]}
</Text>
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
</View>
))}
<Summenzeile text="Summe eigene Auslagen" betragCent={rechnung.eigeneAuslagenCent} />
</View>
)}
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt} break={voll}>
<Text style={stile.ueberschrift}>
{voll ? 'G. Vermögensbestand am Ende des Zeitraums' : 'E. Vermögensbestand am Ende des Zeitraums'}
</Text>
{daten.vermoegenEnde.aktivaGruppen.length === 0 &&
daten.vermoegenEnde.passiva.length === 0 ? (
<Text style={stile.text}>Keine Vermögensposten erfasst.</Text>
) : (
<>
{daten.vermoegenEnde.aktivaGruppen.map((gruppe) => (
<View key={gruppe.art} wrap={false}>
<Text style={stile.untertitel}>
{VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
</Text>
{gruppe.posten.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
<Text style={[stile.klein, stile.schmal]}>
{formatDatum(posten.bewertetAm)}
</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
</View>
))}
{daten.vermoegenEnde.passiva.length > 0 && (
<>
<Text style={stile.untertitel}>Verbindlichkeiten</Text>
{daten.vermoegenEnde.passiva.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
<Text style={[stile.klein, stile.schmal]}>
{formatDatum(posten.bewertetAm)}
</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
</>
)}
<Summenzeile
text="Reinvermögen aus Vermögensposten"
betragCent={daten.vermoegenEnde.reinvermoegenCent}
/>
<Text style={stile.klein}>
Kontoguthaben sind hier nicht enthalten – sie stehen unter A. Bestände.
</Text>
</>
)}
</View>
{/* ------------------------------------------------------------- */}
{voll && (
<View style={stile.abschnitt} break>
<Text style={stile.ueberschrift}>H. Belegverzeichnis</Text>
<Text style={stile.erklaerung}>
Laufende Nummerierung nach Datum. Die Belege sind in dieser Reihenfolge abgeheftet.
</Text>
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.nummer]}>Nr.</Text>
<Text style={[stile.kopftext, stile.datum]}>Datum</Text>
<Text style={[stile.kopftext, stile.breit]}>Vorgang</Text>
<Text style={[stile.kopftext, stile.mittel]}>Beleg</Text>
<Text style={[stile.kopftext, stile.betrag]}>Betrag</Text>
</View>
{rechnung.belege.map((beleg) => (
<View key={beleg.buchungId} style={stile.zeile}>
<Text style={stile.nummer}>{beleg.nummer}</Text>
<Text style={stile.datum}>{formatDatum(beleg.datum)}</Text>
<View style={stile.breit}>
<Text style={stile.text}>{beleg.beschriftung}</Text>
{beleg.verwendungszweck && beleg.gegenstelle && (
<Text style={stile.klein}>{beleg.verwendungszweck}</Text>
)}
</View>
<View style={stile.mittel}>
{beleg.belegTitel ? (
<Text style={stile.klein}>{beleg.belegTitel}</Text>
) : beleg.belegFehltBegruendung ? (
<Text style={stile.klein}>
ohne Beleg – {beleg.belegFehltBegruendung}
</Text>
) : (
<Text style={[stile.klein, stile.fett]}>Beleg fehlt</Text>
)}
</View>
<Text style={stile.betrag}>
{beleg.richtung === 'AUSGABE'
? formatCent(-beleg.betragCent)
: formatCent(beleg.betragCent)}
</Text>
</View>
))}
</View>
)}
{!voll && rechnung.beleg.ohneNachweis > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Belege</Text>
<Text style={stile.hinweistext}>
Von {rechnung.beleg.gesamt} Buchungen des Berichtszeitraums sind{' '}
{rechnung.beleg.mitBeleg} mit einem Beleg hinterlegt, {rechnung.beleg.begruendet}{' '}
tragen eine Begründung für den fehlenden Beleg, und{' '}
{rechnung.beleg.ohneNachweis} haben beides nicht.
</Text>
</View>
)}
{rechnung.hinweise.length > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, stile.fett]}>Anmerkungen zur Aufstellung</Text>
{rechnung.hinweise.map((hinweis, i) => (
<Text key={i} style={stile.hinweistext}>
• {hinweis}
</Text>
))}
</View>
)}
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
Ich versichere, die Aufstellung nach bestem Wissen und Gewissen und auf Grundlage der
vorliegenden Kontoauszüge und Belege erstellt zu haben.
</Text>
</Berichtsseite>
</Document>
)
}
export async function rechnungslegungPdf(daten: RechnungslegungDaten): Promise<Buffer> {
return pdfErzeugen(<RechnungslegungDokument daten={daten} />)
}
export function rechnungslegungDateiname(daten: RechnungslegungDaten): string {
const bis = daten.jahr.bis.toISOString().slice(0, 10)
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
const art =
daten.rechnung.variante === 'UEBERSICHT_1859' ? 'Vermoegensuebersicht' : 'Rechnungslegung'
return `${bis}-${art}-Betreuungsjahr-${daten.jahr.nummer}-${name}.pdf`
}
+441
View File
@@ -0,0 +1,441 @@
import 'server-only'
import { createElement as e, type ReactElement, type ReactNode } from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
import { paginieren, pdfErzeugen, pdfsZusammenfuehren } from '@/lib/pdf/erzeugen'
import { lesen } from '@/lib/speicher'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { berichtDateiname } from '@/lib/validierung/berichte'
/**
* Unterlagenpaket fuer den Sozialhilfetraeger.
*
* Der Bezirk fordert Unterlagen nach - meist zehn bis zwanzig Positionen,
* verteilt ueber mehrere Schreiben. Wer darauf mit einem Stapel loser
* Kopien antwortet, bekommt Wochen spaeter die Nachfrage, welche Unterlage
* denn nun zu welcher Position gehoere.
*
* Deshalb entsteht hier EIN PDF: Anschreiben mit laufender Nummerierung,
* Inhaltsverzeichnis, dann die Unterlagen in genau dieser Reihenfolge,
* durchpaginiert. Auch das, was es nicht gibt, steht drin - mit Begruendung.
* Eine nicht beantwortete Position gilt beim Traeger sonst als fehlende
* Mitwirkung, und das kostet Leistung.
*
* Ohne JSX, weil die Datei nach dem Bauplan .ts heisst.
*/
/**
* Anschreiben und Inhaltsverzeichnis liegen vor den Anlagen. Beide sind auf
* je eine Seite hin gestaltet; wird eines laenger, verschiebt sich die
* Angabe im Verzeichnis um denselben Betrag nach hinten. Das nehmen wir in
* Kauf - die laufende Nummer bleibt in jedem Fall richtig, und danach
* sucht der Sachbearbeiter.
*/
const VORSPANN_SEITEN = 2
const stile = StyleSheet.create({
absatz: { fontSize: 10, lineHeight: 1.5, marginBottom: 8, textAlign: 'justify' },
betreff: { fontSize: 11, fontFamily: 'Helvetica-Bold', marginBottom: 10 },
ueberschrift: { fontSize: 12, fontFamily: 'Helvetica-Bold', marginTop: 16, marginBottom: 6 },
anschrift: { fontSize: 10, lineHeight: 1.4, marginBottom: 18 },
zeile: { flexDirection: 'row', marginBottom: 4 },
nummer: { fontSize: 10, width: 28 },
bezeichnung: { fontSize: 10, flexGrow: 1, paddingRight: 8 },
bemerkung: { fontSize: 9, color: '#555555', width: 150, textAlign: 'right' },
fehlend: { fontSize: 10, lineHeight: 1.45, marginBottom: 6, marginLeft: 28 },
})
// -----------------------------------------------------------------------------
// Aufstellung
// -----------------------------------------------------------------------------
export type Unterlage = {
id: string
bezeichnung: string
erlaeuterung: string | null
status: string
begruendungNichtVorhanden: string | null
reihenfolge: number
/** Angehaengte Dokumente in der Reihenfolge, in der sie eingescannt wurden. */
dokumente: {
id: string
titel: string
schreibenVom: Date | null
objektSchluessel: string | null
mimeTyp: string | null
seitenzahl: number | null
}[]
}
export type Aufstellung = {
/** Position im Anschreiben - beginnt bei 1 und zaehlt lueckenlos hoch. */
nummer: number
unterlage: Unterlage
beigefuegt: boolean
}
/**
* Nummeriert die ausgewaehlten Positionen. Reine Funktion, damit die
* laufende Nummer im Anschreiben, im Inhaltsverzeichnis und in der
* Reihenfolge der angehaengten Seiten garantiert dieselbe ist.
*/
export function unterlagenAufstellen(unterlagen: Unterlage[]): Aufstellung[] {
return [...unterlagen]
.sort((a, b) => a.reihenfolge - b.reihenfolge || a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
.map((unterlage, i) => ({
nummer: i + 1,
unterlage,
beigefuegt: unterlage.dokumente.some((d) => d.objektSchluessel !== null),
}))
}
// -----------------------------------------------------------------------------
// Anschreiben und Inhaltsverzeichnis
// -----------------------------------------------------------------------------
export type Anschreibendaten = {
traeger: string
leistungsart: string
aktenzeichen: string | null
sachbearbeiter: string | null
betreuteName: string
betreuteGeburtsdatum: Date | null
erstelltVon: string
erstelltAm: Date
/** Freitext, den der Betreuer im Formular ergaenzt hat. */
zusatz: string
}
function anschriftBlock(daten: Anschreibendaten) {
const zeilen = [daten.traeger]
if (daten.sachbearbeiter) zeilen.push(`z. Hd. ${daten.sachbearbeiter}`)
return e(
View,
{ style: stile.anschrift },
...zeilen.map((zeileText, i) => e(Text, { key: `an-${i}` }, zeileText)),
)
}
function positionsZeile(eintrag: Aufstellung) {
const bemerkung = eintrag.beigefuegt
? `Anlage ${eintrag.nummer}`
: 'nicht vorhanden – siehe Erklärung'
return e(
View,
{ key: `pos-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
e(Text, { style: stile.bemerkung }, bemerkung),
)
}
/** Das Anschreiben als eigenstaendiges react-pdf-Dokument. */
export function AnschreibenDokument(props: { kopf: Berichtskopf; daten: Anschreibendaten; aufstellung: Aufstellung[] }) {
const { kopf, daten, aufstellung } = props
const beigefuegt = aufstellung.filter((a) => a.beigefuegt)
const fehlend = aufstellung.filter((a) => !a.beigefuegt)
const kinder: ReactNode[] = [
anschriftBlock(daten),
e(
Text,
{ key: 'betreff', style: stile.betreff },
`Betreff: ${daten.leistungsart} für ${daten.betreuteName}` +
(daten.aktenzeichen ? ` – Aktenzeichen ${daten.aktenzeichen}` : ''),
),
e(
Text,
{ key: 'anrede', style: stile.absatz },
daten.sachbearbeiter ? `Sehr geehrte Damen und Herren,` : 'Sehr geehrte Damen und Herren,',
),
e(
Text,
{ key: 'einleitung', style: stile.absatz },
`als rechtlicher Betreuer von ${daten.betreuteName}` +
(daten.betreuteGeburtsdatum ? `, geboren am ${formatDatum(daten.betreuteGeburtsdatum)}` : '') +
`, übersende ich Ihnen die von Ihnen angeforderten Unterlagen. ` +
`Die Unterlagen sind fortlaufend nummeriert und in dieser Reihenfolge diesem Schreiben ` +
`beigefügt; das Inhaltsverzeichnis auf der Folgeseite nennt die zugehörigen Seiten.`,
),
e(Text, { key: 'ueberschrift-liste', style: stile.ueberschrift }, 'Beigefügte Unterlagen'),
]
if (beigefuegt.length > 0) {
kinder.push(e(View, { key: 'liste' }, ...beigefuegt.map(positionsZeile)))
} else {
kinder.push(
e(
Text,
{ key: 'liste-leer', style: stile.absatz },
'Diesem Schreiben liegen keine Unterlagen bei; zu den angeforderten Positionen finden Sie ' +
'unten die jeweilige Erklärung.',
),
)
}
if (fehlend.length > 0) {
kinder.push(
e(
Text,
{ key: 'ueberschrift-fehlend', style: stile.ueberschrift },
'Nicht vorhandene Unterlagen',
),
e(
Text,
{ key: 'fehlend-einleitung', style: stile.absatz },
'Zu den folgenden Positionen kann ich keine Unterlage vorlegen. Ich erkläre dazu:',
),
...fehlend.map((eintrag) =>
e(
Text,
{ key: `fehlend-${eintrag.unterlage.id}`, style: stile.fehlend },
`${eintrag.nummer}. ${eintrag.unterlage.bezeichnung} — ` +
(eintrag.unterlage.begruendungNichtVorhanden?.trim() ||
'Diese Unterlage liegt mir nicht vor. Ich bemühe mich weiter darum und reiche sie nach, sobald sie mir vorliegt.'),
),
),
)
}
if (daten.zusatz.trim() !== '') {
kinder.push(
e(Text, { key: 'zusatz-ueberschrift', style: stile.ueberschrift }, 'Ergänzende Hinweise'),
e(Text, { key: 'zusatz', style: stile.absatz }, daten.zusatz.trim()),
)
}
kinder.push(
e(
Text,
{ key: 'schluss', style: stile.absatz },
'Für Rückfragen stehe ich Ihnen gerne zur Verfügung. Ich bitte um eine kurze ' +
'Eingangsbestätigung.',
),
e(Text, { key: 'gruss', style: stile.absatz }, 'Mit freundlichen Grüßen'),
)
return e(
Document,
{ title: kopf.titel, author: daten.erstelltVon, language: 'de' },
e(Berichtsseite, { kopf, mitUnterschrift: true }, ...kinder),
)
}
/** Das Inhaltsverzeichnis mit den Seitenzahlen der Anlagen. */
export function InhaltsverzeichnisDokument(props: {
kopf: Berichtskopf
aufstellung: Aufstellung[]
/** Erste Seite der jeweiligen Anlage im zusammengefuehrten PDF. */
startseiten: Map<string, number>
}) {
const { kopf, aufstellung, startseiten } = props
return e(
Document,
{ title: `${kopf.titel} – Inhaltsverzeichnis`, author: kopf.erstelltVon, language: 'de' },
e(
Berichtsseite,
{ kopf: { ...kopf, titel: `${kopf.titel} – Inhaltsverzeichnis` }, mitUnterschrift: false },
e(
Text,
{ key: 'einleitung', style: stile.absatz },
'Die Seitenzahlen beziehen sich auf die durchlaufende Nummerierung am Fuß dieses ' +
'Dokuments.',
),
...aufstellung.map((eintrag) => {
const seite = startseiten.get(eintrag.unterlage.id)
const bemerkung = eintrag.beigefuegt
? seite !== undefined
? `ab Seite ${seite}`
: 'beigefügt'
: 'nicht vorhanden'
return e(
View,
{ key: `inh-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
e(Text, { style: stile.bemerkung }, bemerkung),
)
}),
),
)
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
export type UnterlagenpaketErgebnis = {
pdf: Buffer
dateiname: string
aufstellung: Aufstellung[]
/** Unterlagen, deren Datei nicht gelesen werden konnte. */
uebersprungen: { bezeichnung: string; grund: string }[]
}
/**
* Erzeugt das komplette Paket. Der Aufrufer hat den Zugriff auf die
* betreute Person bereits geprueft.
*/
export async function unterlagenpaketErzeugen(optionen: {
betreuteId: string
antragId: string
nachforderungIds: string[]
anschreibenZusatz: string
erstelltVon: string
erstelltAm?: Date
}): Promise<UnterlagenpaketErgebnis> {
const erstelltAm = optionen.erstelltAm ?? new Date()
const [betreute, antrag, nachforderungen] = await Promise.all([
db.betreute.findFirstOrThrow({
where: { id: optionen.betreuteId, ...nichtGeloescht },
select: { vorname: true, nachname: true, anrede: true, geburtsdatum: true },
}),
db.sozialhilfeAntrag.findFirstOrThrow({
where: { id: optionen.antragId, betreuteId: optionen.betreuteId, ...nichtGeloescht },
select: {
traeger: true,
leistungsart: true,
aktenzeichen: true,
sachbearbeiter: true,
},
}),
db.nachforderung.findMany({
where: {
id: { in: optionen.nachforderungIds },
antragId: optionen.antragId,
antrag: { betreuteId: optionen.betreuteId },
...nichtGeloescht,
},
include: {
verknuepfungen: {
where: { betreuteId: optionen.betreuteId },
include: {
dokument: {
select: {
id: true,
titel: true,
schreibenVom: true,
objektSchluessel: true,
mimeTyp: true,
seitenzahl: true,
geloeschtAm: true,
},
},
},
},
},
}),
])
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
.filter(Boolean)
.join(' ')
const unterlagen: Unterlage[] = nachforderungen.map((n) => ({
id: n.id,
bezeichnung: n.bezeichnung,
erlaeuterung: n.erlaeuterung,
status: n.status,
begruendungNichtVorhanden: n.begruendungNichtVorhanden,
reihenfolge: n.reihenfolge,
dokumente: n.verknuepfungen
.map((v) => v.dokument)
.filter((d): d is NonNullable<typeof d> => d !== null && d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
schreibenVom: d.schreibenVom,
objektSchluessel: d.objektSchluessel,
mimeTyp: d.mimeTyp,
seitenzahl: d.seitenzahl,
})),
}))
const aufstellung = unterlagenAufstellen(unterlagen)
const kopf: Berichtskopf = {
titel: 'Unterlagen zur Hilfe zur Pflege',
betreute: { name: betreuteName, geburtsdatum: betreute.geburtsdatum },
behoerde: antrag.traeger,
aktenzeichen: antrag.aktenzeichen ?? undefined,
rechtsgrundlage: '§ 60 SGB I (Mitwirkungspflicht)',
erstelltAm,
erstelltVon: optionen.erstelltVon,
}
const daten: Anschreibendaten = {
traeger: antrag.traeger,
leistungsart: antrag.leistungsart,
aktenzeichen: antrag.aktenzeichen,
sachbearbeiter: antrag.sachbearbeiter,
betreuteName,
betreuteGeburtsdatum: betreute.geburtsdatum,
erstelltVon: optionen.erstelltVon,
erstelltAm,
zusatz: optionen.anschreibenZusatz,
}
// 1. Anlagen einlesen und dabei merken, auf welcher Seite jede beginnt.
const uebersprungen: { bezeichnung: string; grund: string }[] = []
const anlagen: Buffer[] = []
const startseiten = new Map<string, number>()
// Anschreiben und Inhaltsverzeichnis stehen vorne; ihre Seitenzahl steht
// erst nach dem Rendern fest, deshalb zunaechst als Versatz gefuehrt.
let seitenversatz = 0
for (const eintrag of aufstellung) {
if (!eintrag.beigefuegt) continue
for (const dokument of eintrag.unterlage.dokumente) {
if (!dokument.objektSchluessel) continue
try {
const inhalt = await lesen(dokument.objektSchluessel)
if (!startseiten.has(eintrag.unterlage.id)) {
startseiten.set(eintrag.unterlage.id, seitenversatz + 1)
}
anlagen.push(inhalt)
seitenversatz += dokument.seitenzahl ?? 1
} catch (fehler) {
uebersprungen.push({
bezeichnung: `${eintrag.unterlage.bezeichnung} – ${dokument.titel}`,
grund: (fehler as Error).message,
})
}
}
if (!startseiten.has(eintrag.unterlage.id)) {
// Alle Dateien dieser Position waren unlesbar.
eintrag.beigefuegt = false
}
}
const anschreiben = await pdfErzeugen(
AnschreibenDokument({ kopf, daten, aufstellung }) as ReactElement,
)
const verzeichnisSeiten = new Map(
[...startseiten].map(([id, seite]) => [id, seite + VORSPANN_SEITEN]),
)
const inhaltsverzeichnis = await pdfErzeugen(
InhaltsverzeichnisDokument({
kopf,
aufstellung,
startseiten: verzeichnisSeiten,
}) as ReactElement,
)
const zusammengefuehrt = await pdfsZusammenfuehren([anschreiben, inhaltsverzeichnis, ...anlagen])
const pdf = await paginieren(
zusammengefuehrt,
`${betreuteName}${antrag.aktenzeichen ? ` · ${antrag.aktenzeichen}` : ''} · ` +
`Unterlagen vom ${formatDatum(erstelltAm)}`,
)
return {
pdf,
dateiname: berichtDateiname('unterlagen-bezirk', betreute.nachname, erstelltAm, 'pdf'),
aufstellung,
uebersprungen,
}
}
+376
View File
@@ -0,0 +1,376 @@
import 'server-only'
import * as React from 'react'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { formatCent, formatDatum } from '@/lib/utils'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import type { VermoegensverzeichnisDaten } from '@/lib/berichte/gemeinsam'
/**
* Vermoegensverzeichnis nach § 1835 BGB.
*
* Binnen drei Monaten nach der Bestellung beim Betreuungsgericht
* einzureichen. Es beantwortet eine einzige Frage vollstaendig: Was
* gehoert der betreuten Person am Stichtag, und was schuldet sie?
*
* Drei Dinge machen dieses Verzeichnis brauchbar - und alle drei sind
* hier eingebaut:
*
* 1. Jede Zahl traegt ihre Herkunft. Neben jedem Wert steht, woher er
* stammt und von wann er ist. Ein Betrag ohne Quelle ist beim Gericht
* wertlos.
* 2. Luecken werden benannt, nicht mit Nullen gefuellt. Ein Posten ohne
* Bewertung steht ausdruecklich als "noch nicht bewertet" drin.
* 3. Neben dem Bestand stehen die laufenden Einnahmen und Ausgaben.
* Danach fragt das Gericht als Naechstes, und danach fragt der Bezirk
* im Verfahren zur Hilfe zur Pflege.
*/
const stile = StyleSheet.create({
abschnitt: { marginTop: 14 },
ueberschrift: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#333333',
},
untertitel: {
fontSize: 9.5,
fontFamily: 'Helvetica-Bold',
marginTop: 8,
marginBottom: 3,
},
zeile: {
flexDirection: 'row',
paddingVertical: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#dddddd',
},
kopfzeile: {
flexDirection: 'row',
paddingBottom: 2,
borderBottomWidth: 1,
borderBottomColor: '#999999',
},
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
text: { fontSize: 9 },
klein: { fontSize: 7.5, color: '#555555' },
breit: { flex: 1, paddingRight: 6 },
mittel: { width: 120, paddingRight: 6 },
schmal: { width: 62, paddingRight: 6 },
betrag: { width: 82, textAlign: 'right', fontSize: 9 },
summenzeile: {
flexDirection: 'row',
paddingTop: 4,
marginTop: 2,
borderTopWidth: 1,
borderTopColor: '#333333',
},
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
summenbetrag: { width: 82, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
hinweisblock: {
marginTop: 12,
padding: 8,
borderWidth: 1,
borderColor: '#b45309',
backgroundColor: '#fffbeb',
},
hinweistext: { fontSize: 8, marginBottom: 3 },
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
})
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
return (
<View style={stile.summenzeile}>
<Text style={stile.summentext}>{text}</Text>
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
</View>
)
}
function Tabellenkopf({ spalten }: { spalten: [string, string, string, string] }) {
return (
<View style={stile.kopfzeile}>
<Text style={[stile.kopftext, stile.breit]}>{spalten[0]}</Text>
<Text style={[stile.kopftext, stile.mittel]}>{spalten[1]}</Text>
<Text style={[stile.kopftext, stile.schmal]}>{spalten[2]}</Text>
<Text style={[stile.kopftext, stile.betrag]}>{spalten[3]}</Text>
</View>
)
}
export function VermoegensverzeichnisDokument({
daten,
}: {
daten: VermoegensverzeichnisDaten
}): React.ReactElement {
const kontenOhneBetrag = daten.kontostaende.filter((k) => k.betragCent === null)
return (
<Document
title={`Vermögensverzeichnis ${daten.betreute.name}`}
author={daten.kopf.erstelltVon}
subject={`Vermögensverzeichnis nach § 1835 BGB zum ${formatDatum(daten.stichtag)}`}
>
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
<Text style={stile.erklaerung}>
Verzeichnis des Vermögens der betreuten Person zum Stichtag {formatDatum(daten.stichtag)},
aufgestellt nach § 1835 BGB. Jeder Wert ist mit seiner Herkunft und dem Tag seiner
Bewertung angegeben. Beträge, für die zum Stichtag keine belastbare Bewertung vorliegt,
sind am Ende des Verzeichnisses gesondert aufgeführt und nicht in die Summen eingerechnet.
</Text>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>A. Aktiva</Text>
<Text style={stile.untertitel}>I. Konten und Guthaben</Text>
{daten.kontostaende.length === 0 ? (
<Text style={stile.text}>Keine Konten erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Konto', 'Bank / IBAN', 'Stand vom', 'Betrag']} />
{daten.kontostaende.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{konto.bezeichnung}</Text>
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
</View>
<View style={stile.schmal}>
<Text style={stile.klein}>{formatDatum(konto.stichtag)}</Text>
{konto.quelle && <Text style={stile.klein}>{konto.quelle}</Text>}
</View>
<Text style={stile.betrag}>
{konto.betragCent === null ? 'nicht erfasst' : formatCent(konto.betragCent)}
</Text>
</View>
))}
<Summenzeile text="Summe Konten und Guthaben" betragCent={daten.summeKontenCent} />
</>
)}
{daten.vermoegen.aktivaGruppen.map((gruppe, i) => (
<View key={gruppe.art} wrap={false}>
<Text style={stile.untertitel}>
{romanisch(i + 2)}. {VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
</Text>
<Tabellenkopf spalten={['Posten', 'Wertquelle / Nachweis', 'Bewertet', 'Wert']} />
{gruppe.posten.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<View style={stile.breit}>
<Text style={stile.text}>{posten.bezeichnung}</Text>
</View>
<View style={stile.mittel}>
<Text style={stile.klein}>{posten.wertquelle}</Text>
{posten.nachweisTitel && (
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
)}
</View>
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
<Summenzeile
text={`Summe ${VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}`}
betragCent={gruppe.summeCent}
/>
</View>
))}
<Summenzeile text="Summe Aktiva" betragCent={daten.summeAktivaCent} />
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>B. Passiva (Verbindlichkeiten)</Text>
{daten.vermoegen.passiva.length === 0 ? (
<Text style={stile.text}>Keine Verbindlichkeiten erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Verbindlichkeit', 'Quelle / Nachweis', 'Bewertet', 'Betrag']} />
{daten.vermoegen.passiva.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<View style={stile.mittel}>
<Text style={stile.klein}>{posten.wertquelle}</Text>
{posten.nachweisTitel && (
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
)}
</View>
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
</View>
))}
<Summenzeile text="Summe Passiva" betragCent={daten.summePassivaCent} />
</>
)}
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>C. Reinvermögen zum Stichtag</Text>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>Aktiva</Text>
<Text style={stile.betrag}>{formatCent(daten.summeAktivaCent)}</Text>
</View>
<View style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>abzüglich Passiva</Text>
<Text style={stile.betrag}>{formatCent(-daten.summePassivaCent)}</Text>
</View>
<Summenzeile text="Reinvermögen" betragCent={daten.reinvermoegenCent} />
</View>
{/* ------------------------------------------------------------- */}
<View style={stile.abschnitt} break>
<Text style={stile.ueberschrift}>
D. Laufende Einnahmen und Ausgaben ({daten.laufend.monate} Monate bis{' '}
{formatDatum(daten.laufend.bis)})
</Text>
<Text style={stile.erklaerung}>
Grundlage sind die im Kassenbuch erfassten Buchungen des genannten Zeitraums, auf den
Monat heruntergerechnet. Einmalige vermögenswirksame Vorgänge – etwa ein Verkaufserlös
oder ein Rückkaufswert – sind hier bewusst nicht enthalten; sie sind keine laufenden
Einnahmen und stehen im Bestandsteil dieses Verzeichnisses.
</Text>
<Text style={stile.untertitel}>Einnahmen</Text>
{daten.laufend.einnahmen.length === 0 ? (
<Text style={stile.text}>Keine laufenden Einnahmen erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Art der Einnahme', '', 'Vorgänge', 'je Monat']} />
{daten.laufend.einnahmen.map((posten) => (
<View key={posten.kategorie} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Zeitraum gesamt: {formatCent(posten.summeCent)}
</Text>
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
</View>
))}
<Summenzeile
text="Summe laufende Einnahmen je Monat"
betragCent={daten.laufend.monatlichEinnahmenCent}
/>
</>
)}
<Text style={stile.untertitel}>Ausgaben</Text>
{daten.laufend.ausgaben.length === 0 ? (
<Text style={stile.text}>Keine laufenden Ausgaben erfasst.</Text>
) : (
<>
<Tabellenkopf spalten={['Art der Ausgabe', '', 'Vorgänge', 'je Monat']} />
{daten.laufend.ausgaben.map((posten) => (
<View key={posten.kategorie} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Zeitraum gesamt: {formatCent(posten.summeCent)}
</Text>
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
</View>
))}
<Summenzeile
text="Summe laufende Ausgaben je Monat"
betragCent={daten.laufend.monatlichAusgabenCent}
/>
</>
)}
<Summenzeile
text="Rechnerischer Überschuss / Fehlbetrag je Monat"
betragCent={
daten.laufend.monatlichEinnahmenCent - daten.laufend.monatlichAusgabenCent
}
/>
</View>
{/* ------------------------------------------------------------- */}
{(daten.vermoegen.ohneWert.length > 0 || kontenOhneBetrag.length > 0) && (
<View style={stile.abschnitt}>
<Text style={stile.ueberschrift}>E. Noch nicht bewertete Posten</Text>
<Text style={stile.erklaerung}>
Diese Posten sind vorhanden, konnten zum Stichtag aber nicht belastbar bewertet
werden. Sie sind in keiner Summe dieses Verzeichnisses enthalten.
</Text>
{kontenOhneBetrag.map((konto) => (
<View key={konto.kontoId} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{konto.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>
Kein Saldostand zum Stichtag erfasst.
</Text>
</View>
))}
{daten.vermoegen.ohneWert.map((posten) => (
<View key={posten.id} style={stile.zeile}>
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
<Text style={[stile.klein, stile.mittel]}>
{VERMOEGENS_ART_BESCHRIFTUNG[posten.art]} – keine Bewertung zum Stichtag.
</Text>
</View>
))}
</View>
)}
{daten.hinweise.length > 0 && (
<View style={stile.hinweisblock}>
<Text style={[stile.hinweistext, { fontFamily: 'Helvetica-Bold' }]}>
Anmerkungen zur Aufstellung
</Text>
{daten.hinweise.map((hinweis, i) => (
<Text key={i} style={stile.hinweistext}>
• {hinweis}
</Text>
))}
</View>
)}
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
Ich versichere, das Verzeichnis nach bestem Wissen und Gewissen aufgestellt zu haben.
Die angegebenen Werte beruhen auf den jeweils genannten Quellen; die zugehörigen
Nachweise können auf Anforderung vorgelegt werden.
</Text>
</Berichtsseite>
</Document>
)
}
/** I, II, III … fuer die Gliederung der Aktiva. */
function romanisch(zahl: number): string {
const zeichen: [number, string][] = [
[10, 'X'],
[9, 'IX'],
[5, 'V'],
[4, 'IV'],
[1, 'I'],
]
let rest = zahl
let ergebnis = ''
for (const [wert, text] of zeichen) {
while (rest >= wert) {
ergebnis += text
rest -= wert
}
}
return ergebnis
}
export async function vermoegensverzeichnisPdf(daten: VermoegensverzeichnisDaten): Promise<Buffer> {
return pdfErzeugen(<VermoegensverzeichnisDokument daten={daten} />)
}
/** Dateiname fuer den Download - Datum voran, damit die Ablage sortiert. */
export function vermoegensverzeichnisDateiname(daten: VermoegensverzeichnisDaten): string {
const stichtag = daten.stichtag.toISOString().slice(0, 10)
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
return `${stichtag}-Vermoegensverzeichnis-${name}.pdf`
}
+531
View File
@@ -0,0 +1,531 @@
import 'server-only'
import { createHash } from 'node:crypto'
import type { Readable } from 'node:stream'
import archiver from 'archiver'
import { db, nichtGeloescht } from '@/lib/db'
import { lesen } from '@/lib/speicher'
import { oeffnen, VERSCHLUESSELTE_FELDER, type VerschluesseltesModell } from '@/lib/crypto/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
import {
berichtDateiname,
exportDokumentPfad,
pfadErfordern,
sichererDateiname,
} from '@/lib/validierung/berichte'
/**
* Vollexport - alles, was ueber eine betreute Person gespeichert ist, in
* einem ZIP.
*
* Das ist die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine
* Daten gehoeren dir, und du kommst jederzeit ohne mich an sie heran. Der
* Export enthaelt die Originaldateien, die Datenbankinhalte im Klartext als
* JSON, die erzeugten Berichte, eine Anleitung und Pruefsummen. Wer die
* Anwendung morgen abschaltet, kann mit diesem Archiv weiterarbeiten.
*
* Erzeugt wird als Strom. Ein Bestand aus mehreren hundert eingescannten
* Bescheiden waere als Buffer im Speicher ein Problem; hier wandert jede
* Datei einzeln durch: lesen, Pruefsumme bilden, ins Archiv schieben,
* vergessen. Deshalb wird nach jedem Eintrag auf das Ereignis "entry"
* gewartet - das ist der Gegendruck, ohne den der Archivierer schneller
* annimmt, als der Browser abnimmt.
*
* Der Export ist eine sensible Aktion: frische Anmeldung, Rolle BETREUER
* oder ADMIN, Audit-Eintrag EXPORTIERT. Das prueft die Server Action, nicht
* diese Datei.
*/
export type VollexportOptionen = {
betreuteId: string
mitDokumenten: boolean
mitAuditLog: boolean
mitBerichten: boolean
/** Name des Benutzers, der exportiert - steht in der LIESMICH.txt. */
erstelltVon: string
erstelltAm?: Date
}
export type VollexportErgebnis = {
dateiname: string
/** Node-Lesestrom mit dem ZIP. Der Aufrufer reicht ihn an die Antwort weiter. */
strom: Readable
}
type Archiv = ReturnType<typeof archiver>
// -----------------------------------------------------------------------------
// Anhaengen mit Gegendruck und Pruefsumme
// -----------------------------------------------------------------------------
function sha256(inhalt: Buffer | string): string {
return createHash('sha256').update(inhalt).digest('hex')
}
/**
* Haengt einen Eintrag an und wartet, bis er geschrieben ist. Ohne dieses
* Warten staut sich der gesamte Bestand im Arbeitsspeicher, sobald die
* Gegenstelle langsamer ist als die Festplatte.
*/
function anhaengen(archiv: Archiv, pfad: string, inhalt: Buffer | string): Promise<void> {
pfadErfordern(pfad)
return new Promise<void>((erfuellen, ablehnen) => {
const aufraeumen = () => {
archiv.off('entry', beiEintrag)
archiv.off('error', beiFehler)
}
const beiEintrag = (eintrag: { name?: string }) => {
if (eintrag?.name !== pfad) return
aufraeumen()
erfuellen()
}
const beiFehler = (fehler: Error) => {
aufraeumen()
ablehnen(fehler)
}
archiv.on('entry', beiEintrag)
archiv.on('error', beiFehler)
archiv.append(inhalt, { name: pfad })
})
}
/** JSON-fest machen: BigInt wird Zeichenkette, Date wird ISO-Zeitstempel. */
export function alsJson(wert: unknown): string {
return JSON.stringify(
wert,
(_schluessel, inhalt) => (typeof inhalt === 'bigint' ? inhalt.toString() : inhalt),
2,
)
}
/**
* Entfernt die Umschlagfelder und legt stattdessen den Klartext daneben.
* Im Export soll lesbar sein, was in der Anwendung lesbar ist - ein
* verschluesselter String nuetzt niemandem, der das Archiv oeffnet.
*/
export function entschluesseln<T extends Record<string, unknown>>(
modell: VerschluesseltesModell | null,
datensaetze: T[],
): Record<string, unknown>[] {
if (!modell) return datensaetze as Record<string, unknown>[]
const felder = VERSCHLUESSELTE_FELDER[modell] as readonly string[]
return datensaetze.map((datensatz) => {
const geoeffnet = { ...oeffnen(modell, datensatz) } as Record<string, unknown>
for (const feld of felder) delete geoeffnet[feld]
return geoeffnet
})
}
// -----------------------------------------------------------------------------
// Tabellen
// -----------------------------------------------------------------------------
type Tabelle = { name: string; modell: VerschluesseltesModell | null; laden: () => Promise<unknown[]> }
/**
* Alle Tabellen, die zu einer betreuten Person gehoeren. Jede Abfrage ist
* auf die betreuteId eingegrenzt - direkt oder ueber die Beziehung, an der
* sie haengt. Der Papierkorb wird mit ausgegeben: Was geloescht ist, bleibt
* dreissig Tage lang wiederherstellbar, und der Export soll die Lage
* abbilden, nicht schoenen. Das Feld "geloeschtAm" sagt, woran man ist.
*/
function tabellen(betreuteId: string, optionen: VollexportOptionen): Tabelle[] {
const liste: Tabelle[] = [
{
name: 'betreute',
modell: 'Betreute',
laden: () => db.betreute.findMany({ where: { id: betreuteId } }),
},
{
name: 'betreuungszugang',
modell: null,
laden: () =>
db.betreuungsZugang.findMany({
where: { betreuteId },
include: { user: { select: { name: true, email: true, rolle: true } } },
}),
},
{ name: 'vorgang', modell: 'Vorgang', laden: () => db.vorgang.findMany({ where: { betreuteId } }) },
{
name: 'aufgabe',
modell: null,
laden: () => db.aufgabe.findMany({ where: { vorgang: { betreuteId } } }),
},
{
name: 'genehmigung',
modell: 'Genehmigung',
laden: () => db.genehmigung.findMany({ where: { betreuteId } }),
},
{ name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) },
{
name: 'dokument',
modell: 'Dokument',
laden: () =>
db.dokument.findMany({
where: { betreuteId },
// Der Suchvektor ist ein Datenbankinterna und nicht exportierbar.
select: {
id: true,
titel: true,
art: true,
richtung: true,
schreibenVom: true,
eingangAm: true,
versandAm: true,
absender: true,
empfaenger: true,
aktenzeichen: true,
objektSchluessel: true,
dateiname: true,
mimeTyp: true,
groesseBytes: true,
pruefsumme: true,
seitenzahl: true,
ocrText: true,
ocrStatus: true,
posteingangStatus: true,
ablageortPapier: true,
wiedervorlageAm: true,
ablaufAm: true,
notizenVerschluesselt: true,
erstelltAm: true,
aktualisiertAm: true,
geloeschtAm: true,
},
}),
},
{
name: 'dokument_verknuepfung',
modell: null,
laden: () => db.dokumentVerknuepfung.findMany({ where: { betreuteId } }),
},
{ name: 'tag', modell: null, laden: () => db.tag.findMany({ where: { betreuteId } }) },
{ name: 'konto', modell: 'Konto', laden: () => db.konto.findMany({ where: { betreuteId } }) },
{
name: 'saldostand',
modell: null,
laden: () => db.saldostand.findMany({ where: { konto: { betreuteId } } }),
},
{ name: 'buchung', modell: 'Buchung', laden: () => db.buchung.findMany({ where: { betreuteId } }) },
{
name: 'vermoegensposten',
modell: 'Vermoegensposten',
laden: () => db.vermoegensposten.findMany({ where: { betreuteId } }),
},
{
name: 'wertstand',
modell: null,
laden: () => db.wertstand.findMany({ where: { vermoegensposten: { betreuteId } } }),
},
{
name: 'immobilie',
modell: 'Immobilie',
laden: () => db.immobilie.findMany({ where: { betreuteId } }),
},
{
name: 'belastung',
modell: null,
laden: () => db.belastung.findMany({ where: { immobilie: { betreuteId } } }),
},
{
name: 'immobilien_checkpunkt',
modell: null,
laden: () => db.immobilienCheckpunkt.findMany({ where: { immobilie: { betreuteId } } }),
},
{ name: 'vertrag', modell: 'Vertrag', laden: () => db.vertrag.findMany({ where: { betreuteId } }) },
{ name: 'kontakt', modell: 'Kontakt', laden: () => db.kontakt.findMany({ where: { betreuteId } }) },
{
name: 'kontaktprotokoll',
modell: 'Kontaktprotokoll',
laden: () => db.kontaktprotokoll.findMany({ where: { betreuteId } }),
},
{
name: 'gesundheitsereignis',
modell: 'Gesundheitsereignis',
laden: () => db.gesundheitsereignis.findMany({ where: { betreuteId } }),
},
{
name: 'heimvertrag',
modell: 'Heimvertrag',
laden: () => db.heimvertrag.findMany({ where: { betreuteId } }),
},
{
name: 'heimkostenmonat',
modell: null,
laden: () => db.heimkostenmonat.findMany({ where: { betreuteId } }),
},
{
name: 'sozialhilfe_antrag',
modell: 'SozialhilfeAntrag',
laden: () => db.sozialhilfeAntrag.findMany({ where: { betreuteId } }),
},
{
name: 'nachforderung',
modell: null,
laden: () => db.nachforderung.findMany({ where: { antrag: { betreuteId } } }),
},
{
name: 'bescheid',
modell: 'Bescheid',
laden: () => db.bescheid.findMany({ where: { antrag: { betreuteId } } }),
},
{ name: 'leistung', modell: null, laden: () => db.leistung.findMany({ where: { betreuteId } }) },
{ name: 'bericht', modell: 'Bericht', laden: () => db.bericht.findMany({ where: { betreuteId } }) },
]
if (optionen.mitAuditLog) {
liste.push({
name: 'audit_log',
modell: 'AuditLog',
laden: () => db.auditLog.findMany({ where: { betreuteId }, orderBy: { zeitpunkt: 'asc' } }),
})
}
return liste
}
// -----------------------------------------------------------------------------
// LIESMICH
// -----------------------------------------------------------------------------
export function liesmichText(angaben: {
betreuteName: string
erstelltVon: string
erstelltAm: Date
tabellen: string[]
anzahlDokumente: number
anzahlBerichte: number
mitAuditLog: boolean
}): string {
return [
'Vollexport aus dem Betreuungs-Cockpit',
'=====================================',
'',
`Betreute Person: ${angaben.betreuteName}`,
`Erzeugt am: ${formatDatumZeit(angaben.erstelltAm)}`,
`Erzeugt von: ${angaben.erstelltVon}`,
'',
'Was ist hier drin?',
'------------------',
'',
'dokumente/<jahr>/<datum>_<absender>_<titel>.<endung>',
' Alle hochgeladenen Originaldateien - Bescheide, Kontoauszüge, Rechnungen,',
' Beschlüsse, Fotos. Unverändert so, wie sie eingegangen sind.',
` Enthalten: ${angaben.anzahlDokumente} Dateien.`,
'',
'daten/*.json',
' Der komplette Datenbestand als JSON, eine Datei je Tabelle. Verschlüsselte',
' Felder sind hier im Klartext enthalten (z.B. "notizen" statt',
' "notizenVerschluesselt"). Geldbeträge stehen als Zeichenkette in CENT -',
' "123456" bedeutet 1.234,56 Euro. Datumsangaben sind ISO-8601 (UTC).',
` Enthalten: ${angaben.tabellen.join(', ')}.`,
angaben.mitAuditLog
? ' Das Audit-Log protokolliert, wer wann was geändert hat.'
: ' Das Audit-Log wurde für diesen Export abgewählt.',
'',
'berichte/*.pdf',
' Die erzeugten und abgelegten Berichte - Jahresberichte, Vermögensverzeichnis,',
` Belegmappen, Unterlagenpakete. Enthalten: ${angaben.anzahlBerichte} Dateien.`,
'',
'pruefsummen.txt',
' SHA-256 je Datei in diesem Archiv, im Format "<hash> <pfad>". Damit lässt',
' sich jederzeit belegen, dass eine Datei unverändert ist. Prüfen kannst du',
' das unter Linux/macOS mit "shasum -a 256 -c pruefsummen.txt", unter Windows',
' mit "certutil -hashfile <datei> SHA256".',
'',
'Wie arbeite ich ohne diese Anwendung weiter?',
'-------------------------------------------',
'',
'1. Entpacke das Archiv in einen Ordner, den du sicherst (Backup!). Die Daten',
' sind hier NICHT mehr verschlüsselt - behandle den Ordner so vertraulich wie',
' den Aktenordner selbst.',
'2. Die Ordnerstruktur unter dokumente/ ist nach Jahren sortiert und selbst',
' erklärend. Für das Betreuungsgericht und den Sozialhilfeträger reicht sie',
' als Aktenführung aus.',
'3. Die JSON-Dateien öffnest du mit jedem Texteditor. Wer eine Tabelle daraus',
' machen will, lädt sie in LibreOffice Calc oder Excel (Daten -> Aus Datei ->',
' JSON) oder nutzt ein Werkzeug wie "jq".',
'4. Die laufenden Pflichten bleiben bestehen, auch ohne Software: Jahresbericht',
' nach § 1863 BGB, Vermögensübersicht nach § 1859 BGB, die Aufwandspauschale',
' nach § 1878 BGB innerhalb von sechs Monaten nach Ende des Betreuungsjahres',
' (Ausschlussfrist!). Übertrage die Fristen aus daten/frist.json in deinen',
' Kalender, bevor du die Anwendung abschaltest.',
'',
'Dieses Archiv ist keine Rechtsberatung und ersetzt keine Rücksprache mit dem',
'Betreuungsgericht, dem Sozialhilfeträger oder einem Anwalt.',
'',
].join('\n')
}
// -----------------------------------------------------------------------------
// Der Export selbst
// -----------------------------------------------------------------------------
export async function vollexportStroemen(optionen: VollexportOptionen): Promise<VollexportErgebnis> {
const erstelltAm = optionen.erstelltAm ?? new Date()
const betreute = await db.betreute.findFirstOrThrow({
where: { id: optionen.betreuteId, ...nichtGeloescht },
select: { vorname: true, nachname: true, anrede: true },
})
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
.filter(Boolean)
.join(' ')
const archiv = archiver('zip', { zlib: { level: 9 } })
// Fehlende Dateien im Objektspeicher duerfen den Lauf nicht abbrechen.
archiv.on('warning', (warnung) => {
console.warn('[vollexport] Warnung beim Archivieren:', warnung.message)
})
const dateiname = berichtDateiname('vollexport', betreute.nachname, erstelltAm, 'zip')
// Der Aufbau laeuft im Hintergrund weiter, waehrend der Aufrufer bereits
// den Strom weiterreicht - genau das macht den Export streamfaehig.
void befuellen(archiv, optionen, { betreuteName, erstelltAm }).catch((fehler: Error) => {
console.error('[vollexport] Abbruch:', fehler)
archiv.abort()
})
return { dateiname, strom: archiv }
}
async function befuellen(
archiv: Archiv,
optionen: VollexportOptionen,
zusatz: { betreuteName: string; erstelltAm: Date },
): Promise<void> {
const pruefsummen: { pfad: string; hash: string }[] = []
const vergeben = new Set<string>()
/** Verhindert, dass zwei Dokumente mit gleichem Titel einander verdecken. */
const eindeutig = (pfad: string): string => {
if (!vergeben.has(pfad)) {
vergeben.add(pfad)
return pfad
}
const punkt = pfad.lastIndexOf('.')
const stamm = punkt < 0 ? pfad : pfad.slice(0, punkt)
const endung = punkt < 0 ? '' : pfad.slice(punkt)
let n = 2
while (vergeben.has(`${stamm}-${n}${endung}`)) n++
const neu = `${stamm}-${n}${endung}`
vergeben.add(neu)
return neu
}
const schreiben = async (pfad: string, inhalt: Buffer | string) => {
const ziel = eindeutig(pfadErfordern(pfad))
pruefsummen.push({ pfad: ziel, hash: sha256(inhalt) })
await anhaengen(archiv, ziel, inhalt)
}
// --- 1. Daten ------------------------------------------------------------
const verwendeteTabellen: string[] = []
for (const tabelle of tabellen(optionen.betreuteId, optionen)) {
const datensaetze = (await tabelle.laden()) as Record<string, unknown>[]
const offen = entschluesseln(tabelle.modell, datensaetze)
await schreiben(`daten/${sichererDateiname(tabelle.name, 'tabelle')}.json`, alsJson(offen))
verwendeteTabellen.push(tabelle.name)
}
// --- 2. Dokumente --------------------------------------------------------
let anzahlDokumente = 0
if (optionen.mitDokumenten) {
const dokumente = await db.dokument.findMany({
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
select: {
titel: true,
absender: true,
schreibenVom: true,
eingangAm: true,
erstelltAm: true,
objektSchluessel: true,
dateiname: true,
mimeTyp: true,
},
orderBy: { erstelltAm: 'asc' },
})
for (const dokument of dokumente) {
if (!dokument.objektSchluessel) continue
let inhalt: Buffer
try {
inhalt = await lesen(dokument.objektSchluessel)
} catch (fehler) {
console.warn(
`[vollexport] Datei zu "${dokument.titel}" nicht lesbar, wird übersprungen:`,
(fehler as Error).message,
)
continue
}
const pfad = exportDokumentPfad({
datum: dokument.schreibenVom ?? dokument.eingangAm ?? dokument.erstelltAm,
absender: dokument.absender,
titel: dokument.titel,
dateiname: dokument.dateiname,
mimeTyp: dokument.mimeTyp,
})
await schreiben(pfad, inhalt)
anzahlDokumente++
}
}
// --- 3. Berichte ---------------------------------------------------------
let anzahlBerichte = 0
if (optionen.mitBerichten) {
const berichte = await db.bericht.findMany({
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
select: { titel: true, art: true, dateiname: true, objektSchluessel: true, erstelltAm: true },
orderBy: { erstelltAm: 'asc' },
})
for (const bericht of berichte) {
if (!bericht.objektSchluessel) continue
let inhalt: Buffer
try {
inhalt = await lesen(bericht.objektSchluessel)
} catch (fehler) {
console.warn(
`[vollexport] Bericht "${bericht.titel}" nicht lesbar, wird übersprungen:`,
(fehler as Error).message,
)
continue
}
const name = bericht.dateiname
? sichererDateiname(bericht.dateiname.replace(/\.[a-z0-9]+$/i, ''), 'bericht')
: sichererDateiname(`${bericht.art}-${bericht.titel}`, 'bericht')
await schreiben(
`berichte/${bericht.erstelltAm.toISOString().slice(0, 10)}_${name}.pdf`,
inhalt,
)
anzahlBerichte++
}
}
// --- 4. LIESMICH und Pruefsummen ----------------------------------------
await schreiben(
'LIESMICH.txt',
liesmichText({
betreuteName: zusatz.betreuteName,
erstelltVon: optionen.erstelltVon,
erstelltAm: zusatz.erstelltAm,
tabellen: verwendeteTabellen,
anzahlDokumente,
anzahlBerichte,
mitAuditLog: optionen.mitAuditLog,
}),
)
// Die Pruefsummendatei steht zuletzt und listet alles davor - sie selbst
// kann sich naturgemaess nicht enthalten.
const zeilen = [
`# SHA-256 aller Dateien in diesem Archiv, erzeugt am ${formatDatum(zusatz.erstelltAm)}.`,
'# Prüfen: shasum -a 256 -c pruefsummen.txt',
...pruefsummen.map((eintrag) => `${eintrag.hash} ${eintrag.pfad}`),
'',
].join('\n')
await anhaengen(archiv, 'pruefsummen.txt', zeilen)
await archiv.finalize()
}
+350
View File
@@ -0,0 +1,350 @@
import 'server-only'
import * as React from 'react'
import { Document, Text, View } from '@react-pdf/renderer'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
bildAlsPdf,
paginieren,
pdfErzeugen,
pdfsZusammenfuehren,
seitenzahl as seitenzahlVon,
} from '@/lib/pdf/erzeugen'
import { Abschnitt, Angabe, Berichtsseite, Hinweiskasten, Tabelle, type Berichtskopf } from '@/lib/pdf/rahmen'
import { typErkennen } from '@/lib/speicher/dateityp'
import { lesen } from '@/lib/speicher'
/**
* Belegmappe: alle Dokumente eines Vorgangs oder eines Zeitraums als **eine**
* chronologisch sortierte, durchgehend paginierte PDF.
*
* Genau das wollen Gericht und Bezirk sehen - nicht dreissig einzelne
* Anhaenge, sondern ein Heft mit Deckblatt, Inhaltsverzeichnis und
* fortlaufender Seitenzahl, auf die man sich im Bericht berufen kann
* ("Beleg 7, Seite 23").
*
* Bilder (abfotografierte Belege) werden in einseitige PDF-Seiten gewandelt,
* damit die Mappe in einem Stueck bleibt.
*/
export type Belegmappenauswahl =
| { art: 'vorgang'; vorgangId: string }
| { art: 'zeitraum'; von: Date; bis: Date }
| { art: 'auswahl'; dokumentIds: string[] }
export type Mappenergebnis = {
pdf: Buffer
dateiname: string
/** Wie viele Dokumente sind in der Mappe gelandet? */
anzahl: number
/** Was konnte nicht aufgenommen werden - steht auch im Deckblatt. */
hinweise: string[]
}
type Belegzeile = {
nummer: number
dokumentId: string
datum: Date | null
absender: string
art: string
titel: string
aktenzeichen: string | null
ablageortPapier: string | null
pruefsumme: string | null
seiteVon: number
seiten: number
}
/** Das Datum, nach dem chronologisch sortiert wird. */
function massgeblichesDatum(dokument: {
eingangAm: Date | null
schreibenVom: Date | null
erstelltAm: Date
}): Date {
return dokument.eingangAm ?? dokument.schreibenVom ?? dokument.erstelltAm
}
async function dokumenteHolen(betreuteId: string, auswahl: Belegmappenauswahl) {
const gemeinsam = { betreuteId, ...nichtGeloescht, objektSchluessel: { not: null } } as const
if (auswahl.art === 'vorgang') {
return db.dokument.findMany({
where: { ...gemeinsam, verknuepfungen: { some: { vorgangId: auswahl.vorgangId } } },
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
if (auswahl.art === 'auswahl') {
return db.dokument.findMany({
where: { ...gemeinsam, id: { in: auswahl.dokumentIds.slice(0, 200) } },
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
return db.dokument.findMany({
where: {
...gemeinsam,
OR: [
{ eingangAm: { gte: auswahl.von, lte: auswahl.bis } },
{ eingangAm: null, schreibenVom: { gte: auswahl.von, lte: auswahl.bis } },
{ eingangAm: null, schreibenVom: null, erstelltAm: { gte: auswahl.von, lte: auswahl.bis } },
],
},
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
})
}
// -----------------------------------------------------------------------------
// Deckblatt und Inhaltsverzeichnis
// -----------------------------------------------------------------------------
function Deckblatt({
kopf,
zeilen,
anzahlSeiten,
hinweise,
beschreibung,
}: {
kopf: Berichtskopf
zeilen: Belegzeile[]
anzahlSeiten: number
hinweise: string[]
beschreibung: string
}): React.JSX.Element {
return (
<Document
title={`Belegmappe – ${kopf.betreute.name}`}
author={kopf.erstelltVon}
creator="Betreuungs-Cockpit"
producer="Betreuungs-Cockpit"
>
<Berichtsseite kopf={kopf} mitUnterschrift>
<Abschnitt titel="Inhalt dieser Mappe">
<Angabe bezeichnung="Zusammenstellung" wert={beschreibung} />
<Angabe bezeichnung="Anzahl Belege" wert={String(zeilen.length)} />
<Angabe
bezeichnung="Umfang"
wert={`${anzahlSeiten} Seite${anzahlSeiten === 1 ? '' : 'n'} einschließlich dieses Deckblatts`}
/>
<Angabe bezeichnung="Zusammengestellt am" wert={formatDatum(kopf.erstelltAm)} />
</Abschnitt>
<Abschnitt titel="Verzeichnis der Belege">
<Tabelle<Belegzeile>
spalten={[
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
{ kopf: 'Datum', breite: 1.5, zelle: (z) => formatDatum(z.datum) },
{ kopf: 'Absender', breite: 3, zelle: (z) => z.absender },
{ kopf: 'Art', breite: 2, zelle: (z) => z.art },
{ kopf: 'Bezeichnung', breite: 4, zelle: (z) => z.titel },
{
kopf: 'Seite',
breite: 1.3,
rechts: true,
zelle: (z) =>
z.seiten > 1 ? `${z.seiteVon}–${z.seiteVon + z.seiten - 1}` : String(z.seiteVon),
},
]}
zeilen={zeilen}
leerText="Für diese Auswahl liegen keine Belege vor."
/>
</Abschnitt>
{zeilen.some((z) => z.ablageortPapier) && (
<Abschnitt titel="Ablageort der Originale">
<Tabelle<Belegzeile>
spalten={[
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
{ kopf: 'Bezeichnung', breite: 5, zelle: (z) => z.titel },
{ kopf: 'Ordner / Register', breite: 4, zelle: (z) => z.ablageortPapier ?? '–' },
]}
zeilen={zeilen.filter((z) => z.ablageortPapier)}
/>
</Abschnitt>
)}
{hinweise.length > 0 && (
<Abschnitt titel="Hinweise">
{hinweise.map((hinweis, i) => (
<Text key={i} style={{ marginBottom: 3 }}>
• {hinweis}
</Text>
))}
</Abschnitt>
)}
<View>
<Hinweiskasten>
Die Belege sind Kopien der bei der Betreuung eingegangenen Unterlagen. Zu jedem Beleg
ist im Betreuungs-Cockpit eine SHA-256-Prüfsumme gespeichert; damit lässt sich
nachweisen, dass die Datei seit der Aufnahme in die Akte nicht verändert wurde. Die
Originale in Papierform sind unter dem oben genannten Ablageort abgeheftet.
</Hinweiskasten>
</View>
</Berichtsseite>
</Document>
)
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Stellt die Mappe zusammen.
*
* Das Deckblatt wird zweimal gerendert: einmal, um seinen eigenen Umfang zu
* kennen, und dann noch einmal mit den richtigen Seitenzahlen. Anders geht
* es nicht - das Verzeichnis muss auf Seiten verweisen, die es selbst
* mitverschiebt.
*/
export async function belegmappeErzeugen(
betreuteId: string,
auswahl: Belegmappenauswahl,
erstelltVon: string,
): Promise<Mappenergebnis> {
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: {
vorname: true,
nachname: true,
geburtsdatum: true,
aktenzeichenGericht: true,
betreuungsgericht: true,
},
})
if (!betreute) throw new Error('Diese betreute Person gibt es nicht.')
const dokumente = await dokumenteHolen(betreuteId, auswahl)
const hinweise: string[] = []
// Jedes Dokument in ein PDF wandeln und seinen Umfang bestimmen.
const teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'>; pdf: Buffer }[] = []
for (const dokument of dokumente) {
if (!dokument.objektSchluessel) continue
try {
const inhalt = await lesen(dokument.objektSchluessel)
const typ = typErkennen(inhalt)
if (!typ) {
hinweise.push(`„${dokument.titel}“ konnte nicht gelesen werden und fehlt in dieser Mappe.`)
continue
}
const alsPdf = typ.istBild
? await bildAlsPdf(
inhalt,
`${dokument.titel} · ${formatDatum(massgeblichesDatum(dokument))}`,
)
: inhalt
const seiten = await seitenzahlVon(alsPdf)
teile.push({
pdf: alsPdf,
zeile: {
dokumentId: dokument.id,
datum: dokument.eingangAm ?? dokument.schreibenVom,
absender: dokument.absender ?? '–',
art: ART_BESCHRIFTUNG[dokument.art],
titel: dokument.titel,
aktenzeichen: dokument.aktenzeichen,
ablageortPapier: dokument.ablageortPapier,
pruefsumme: dokument.pruefsumme,
seiten,
},
})
} catch (fehler) {
console.error('[belegmappe] Dokument übersprungen:', dokument.id, fehler)
hinweise.push(
`„${dokument.titel}“ ließ sich nicht in die Mappe übernehmen (Datei beschädigt oder verschlüsselt).`,
)
}
}
const kopf: Berichtskopf = {
titel: 'Belegmappe',
betreute: {
name: `${betreute.vorname} ${betreute.nachname}`,
geburtsdatum: betreute.geburtsdatum,
},
behoerde: betreute.betreuungsgericht ?? undefined,
aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
zeitraumVon: auswahl.art === 'zeitraum' ? auswahl.von : null,
zeitraumBis: auswahl.art === 'zeitraum' ? auswahl.bis : null,
rechtsgrundlage: '§ 1865 BGB (Rechnungslegung)',
erstelltAm: new Date(),
erstelltVon,
}
const beschreibung = beschreibungFuer(auswahl)
// Zwei Durchgaenge, damit die Seitenzahlen im Verzeichnis stimmen.
let deckblattSeiten = 1
let deckblatt: Buffer = Buffer.alloc(0)
for (let durchgang = 0; durchgang < 3; durchgang++) {
const zeilen = zeilenNummerieren(teile, deckblattSeiten)
const gesamt = deckblattSeiten + teile.reduce((summe, teil) => summe + teil.zeile.seiten, 0)
deckblatt = await pdfErzeugen(
<Deckblatt
kopf={kopf}
zeilen={zeilen}
anzahlSeiten={gesamt}
hinweise={hinweise}
beschreibung={beschreibung}
/>,
)
const gemessen = await seitenzahlVon(deckblatt)
if (gemessen === deckblattSeiten) break
deckblattSeiten = gemessen
}
const zusammengefuehrt = await pdfsZusammenfuehren([deckblatt, ...teile.map((t) => t.pdf)])
const fusszeile = `Belegmappe · ${betreute.vorname} ${betreute.nachname}${
betreute.aktenzeichenGericht ? ` · Az. ${betreute.aktenzeichenGericht}` : ''
} · erstellt am ${formatDatum(kopf.erstelltAm)}`
const pdf = await paginieren(zusammengefuehrt, fusszeile)
return {
pdf,
dateiname: dateinamenFuer(auswahl, betreute.nachname),
anzahl: teile.length,
hinweise,
}
}
function zeilenNummerieren(
teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'> }[],
ersteSeite: number,
): Belegzeile[] {
let seite = ersteSeite + 1
return teile.map((teil, i) => {
const zeile: Belegzeile = { ...teil.zeile, nummer: i + 1, seiteVon: seite }
seite += teil.zeile.seiten
return zeile
})
}
function beschreibungFuer(auswahl: Belegmappenauswahl): string {
if (auswahl.art === 'vorgang') return 'Alle Belege zu einem Vorgang'
if (auswahl.art === 'auswahl') return 'Von Hand zusammengestellte Belege'
return `Belege im Zeitraum ${formatDatum(auswahl.von)} bis ${formatDatum(auswahl.bis)}`
}
function dateinamenFuer(auswahl: Belegmappenauswahl, nachname: string): string {
const teil =
auswahl.art === 'zeitraum'
? `${auswahl.von.toISOString().slice(0, 10)}_bis_${auswahl.bis.toISOString().slice(0, 10)}`
: auswahl.art === 'vorgang'
? 'vorgang'
: 'auswahl'
const sauber = nachname.replace(/[^A-Za-z0-9]/g, '') || 'Akte'
return `Belegmappe_${sauber}_${teil}.pdf`
}
+88 -21
View File
@@ -124,6 +124,39 @@ export function normalisieren(text: string): string {
.replace(/ß/g, 'ss')
}
/**
* Vergleichsform fuer den Namensabgleich: kleingeschrieben, ohne Umlaute,
* und jede Folge von Satz- oder Leerzeichen wird zu einem Leerzeichen.
*
* Damit findet "Bezirk Oberfranken, Sozialverwaltung" aus den Kontakten
* auch den Briefkopf, in dem beides in zwei Zeilen ohne Komma steht.
*
* Der Index zeigt fuer jedes Zeichen der Vergleichsform auf die Stelle im
* Ursprungstext - so laesst sich hinterher die Textstelle ausschneiden.
*/
export function vergleichsform(text: string): { norm: string; index: number[] } {
let norm = ''
const index: number[] = []
let trennerOffen = true
for (let i = 0; i < text.length; i++) {
const ersetzt = normalisieren(text[i]!)
if (/^[a-z0-9]+$/.test(ersetzt)) {
for (const zeichen of ersetzt) {
norm += zeichen
index.push(i)
}
trennerOffen = false
} else if (!trennerOffen) {
norm += ' '
index.push(i)
trennerOffen = true
}
}
return { norm, index }
}
function jahrVervollstaendigen(roh: string): number {
const zahl = Number.parseInt(roh, 10)
if (roh.length === 4) return zahl
@@ -287,7 +320,7 @@ const AZ_MUSTER: AzMuster[] = [
{
// "Az.: XVII 123/25", "Aktenzeichen: 4711/26", "Unser Zeichen: AB/12345"
regex:
/(?:Aktenzeichen|Az\.?|Gesch\.?[-\s]?Z\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
/\b(?:Aktenzeichen|Az\b\.?|Gesch\.?[-\s]?Z\b\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
sicherheit: 'hoch',
gruppe: 1,
},
@@ -299,8 +332,10 @@ const AZ_MUSTER: AzMuster[] = [
gruppe: 1,
},
{
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025"
regex: /\b((?:X{0,3}(?:IX|IV|V?I{0,3}))\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025".
// Die roemische Zahl ist Pflicht - sonst wuerde jedes "12/2025" im
// Text als Aktenzeichen durchgehen.
regex: /\b([IVXL]{1,6}\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
sicherheit: 'mittel',
gruppe: 1,
},
@@ -477,22 +512,37 @@ export function absenderErkennen(
bekannte: string[] = [],
): Vorschlag<string> | null {
const kopfbereich = text.slice(0, 1200)
const normKopf = normalisieren(kopfbereich)
const kopfForm = vergleichsform(kopfbereich)
// 1. Abgleich mit bekannten Absendern
let bester: { name: string; stelle: number } | null = null
// 1. Abgleich mit bekannten Absendern. Wer schon einmal geschrieben hat,
// schreibt wieder - deshalb schlaegt das jede Heuristik.
let bester: { name: string; stelle: number; laenge: number } | null = null
for (const name of bekannte) {
const gekuerzt = name.trim()
if (gekuerzt.length < 4) continue
const stelle = normKopf.indexOf(normalisieren(gekuerzt))
if (stelle >= 0 && (!bester || gekuerzt.length > bester.name.length)) {
bester = { name: gekuerzt, stelle }
const gesucht = vergleichsform(gekuerzt).norm.trim()
if (gesucht.length < 4) continue
const treffer = kopfForm.norm.indexOf(gesucht)
if (treffer < 0) continue
if (bester && gekuerzt.length <= bester.laenge) continue
bester = {
name: gekuerzt,
stelle: kopfForm.index[treffer] ?? 0,
laenge: gekuerzt.length,
}
}
if (bester) {
return {
wert: bester.name,
textstelle: textstelleSchneiden(kopfbereich, bester.stelle, bester.stelle + bester.name.length, 40),
textstelle: textstelleSchneiden(
kopfbereich,
bester.stelle,
bester.stelle + bester.laenge,
40,
),
sicherheit: 'hoch',
}
}
@@ -576,17 +626,26 @@ function anzahlLesen(roh: string): number | null {
return wort ?? null
}
/**
* Fristen werden ueber date-fns gerechnet - aber auf einem Datum in der
* lokalen Zeitzone, mittags. Sonst verschoebe die Sommerzeit eine Frist um
* einen Tag: der 12.03. (Winterzeit) plus vier Wochen ist der 09.04.
* (Sommerzeit), und wer das in UTC rechnet, landet auf dem 08.04. Genau
* dieser Fehler kostet im Ernstfall einen Widerspruch.
*/
function rechnen(basis: Date, anzahl: number, einheit: Einheit): Date {
switch (einheit) {
case 'tage':
return aufTag(addDays(basis, anzahl))
case 'wochen':
return aufTag(addWeeks(basis, anzahl))
case 'monate':
return aufTag(addMonths(basis, anzahl))
case 'jahre':
return aufTag(addYears(basis, anzahl))
}
const lokal = new Date(basis.getUTCFullYear(), basis.getUTCMonth(), basis.getUTCDate(), 12)
const ergebnis =
einheit === 'tage'
? addDays(lokal, anzahl)
: einheit === 'wochen'
? addWeeks(lokal, anzahl)
: einheit === 'monate'
? addMonths(lokal, anzahl)
: addYears(lokal, anzahl)
return tag(ergebnis.getFullYear(), ergebnis.getMonth() + 1, ergebnis.getDate())
}
function einheitText(anzahl: number, einheit: Einheit): string {
@@ -666,6 +725,10 @@ export function fristenErkennen(text: string, basis: Date | null): Fristvorschla
const fund = datenFinden(rest)[0]
if (!fund || fund.von > 3) continue
// "vom 01.01.2025 bis 31.12.2025" ist ein Zeitraum, keine Frist.
const davor = text.slice(Math.max(0, treffer.index - 40), treffer.index)
if (/(?:vom|von)\s+[\d.]{4,12}\s*$/i.test(davor)) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), ab + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const zahlung = /zahl|betrag|überweis|ueberweis|konto|rechnung/i.test(umgebung)
@@ -703,7 +766,11 @@ function entdoppeln(liste: Fristvorschlag[]): Fristvorschlag[] {
.slice()
.sort((a, b) => rang[a.sicherheit] - rang[b.sicherheit])
.filter((vorschlag) => {
const schluessel = `${vorschlag.datum?.toISOString() ?? 'offen'}|${normalisieren(vorschlag.formulierung)}`
// Zwei Formulierungen, die auf denselben Tag hinauslaufen, sind eine
// Frist. Der sicherere Fund gewinnt - deshalb vorher sortiert.
const schluessel = vorschlag.datum
? vorschlag.datum.toISOString()
: normalisieren(vorschlag.formulierung)
if (gesehen.has(schluessel)) return false
gesehen.add(schluessel)
return true
+44
View File
@@ -0,0 +1,44 @@
import 'server-only'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Der gemeinsame Einstieg aller Dokumentenseiten.
*
* Jede Seite beginnt hier - und damit bei der Zugriffspruefung. Es gibt
* keinen Weg an `erfordereZugang` vorbei: die aktive betreute Person kommt
* aus dem Cookie, aber ob sie angezeigt werden darf, entscheidet die
* Datenbank.
*/
export async function dokumentenkontext(art: 'lesen' | 'schreiben' = 'lesen') {
const benutzer = await erfordereBenutzer('/dokumente')
const gewaehlt = await aktiveBetreute(benutzer)
if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.')
const kontext = await erfordereZugang(gewaehlt.id, 'DOKUMENTE', art)
return { ...kontext, betreuteId: gewaehlt.id }
}
/** Alle Schlagworte dieser Akte - fuer Filter und Formulare. */
export async function tagsDerAkte(betreuteId: string) {
return db.tag.findMany({
where: { betreuteId },
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
}
/** Offene und laufende Vorgaenge - Ziel der schnellen Zuordnung. */
export async function vorgaengeDerAkte(betreuteId: string) {
const vorgaenge = await db.vorgang.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, status: true },
orderBy: [{ status: 'asc' }, { faelligAm: 'asc' }, { erstelltAm: 'desc' }],
take: 100,
})
return vorgaenge.map((vorgang) => ({
id: vorgang.id,
titel: vorgang.status === 'ERLEDIGT' ? `${vorgang.titel} (erledigt)` : vorgang.titel,
}))
}
+89
View File
@@ -0,0 +1,89 @@
import 'server-only'
import { db } from '@/lib/db'
import { ocrLesen } from '@/lib/ocr'
import { lesen } from '@/lib/speicher'
/**
* Texterkennung nach dem Hochladen.
*
* Der Upload wartet nicht darauf. Das Dokument wird mit
* `ocrStatus = "ausstehend"` gespeichert, danach laeuft die Erkennung und
* schreibt `ocrText` nach - den Suchvektor fuellt der Trigger in Postgres
* von selbst (prisma/sql/volltextsuche.sql).
*
* Warum nicht warten? Weil Tesseract fuer einen zwanzigseitigen Scan
* Minuten braucht. Wer am Heimbett steht und einen Bescheid abfotografiert,
* soll das Handy weglegen koennen, sobald das Bild oben ist.
*
* Diese Funktion wirft nie. Schlaegt sie fehl, bleibt ein Status stehen,
* der in der Oberflaeche erklaert wird, und das Dokument ist trotzdem in
* der Akte.
*/
export async function ocrNachholen(dokumentId: string): Promise<void> {
const dokument = await db.dokument.findUnique({
where: { id: dokumentId },
select: { id: true, objektSchluessel: true, mimeTyp: true, ocrStatus: true },
})
if (!dokument?.objektSchluessel) return
if (dokument.ocrStatus === 'laeuft') return
try {
await db.dokument.update({ where: { id: dokument.id }, data: { ocrStatus: 'laeuft' } })
const inhalt = await lesen(dokument.objektSchluessel)
const ergebnis = await ocrLesen(inhalt, dokument.mimeTyp ?? 'application/octet-stream')
await db.dokument.update({
where: { id: dokument.id },
data: {
ocrText: ergebnis.text.trim() === '' ? null : ergebnis.text,
ocrStatus: ergebnis.status,
// Die Seitenzahl kennt erst die Erkennung - vorher steht dort nichts.
...(ergebnis.seitenzahl ? { seitenzahl: ergebnis.seitenzahl } : {}),
},
})
} catch (fehler) {
console.error('[ocr] Erkennung fehlgeschlagen fuer', dokumentId, fehler)
try {
await db.dokument.update({
where: { id: dokument.id },
data: { ocrStatus: 'fehlgeschlagen' },
})
} catch {
// Auch das Zurueckschreiben darf nichts abbrechen.
}
}
}
/**
* Startet die Erkennung, ohne auf sie zu warten.
*
* Bewusst ohne Job-Warteschlange: diese Anwendung laeuft als ein einzelner
* Container fuer eine Handvoll Akten. Ein Hintergrundlauf pro Upload ist
* genau die richtige Groessenordnung; ein Redis daneben waere Ballast.
*/
export function ocrAnstossen(dokumentId: string): void {
void ocrNachholen(dokumentId).catch((fehler) => {
console.error('[ocr] Hintergrundlauf gescheitert:', fehler)
})
}
/** Alle haengengebliebenen Dokumente noch einmal versuchen. */
export async function ocrNachziehen(betreuteId: string, grenze = 20): Promise<number> {
const offene = await db.dokument.findMany({
where: {
betreuteId,
geloeschtAm: null,
objektSchluessel: { not: null },
OR: [{ ocrStatus: 'ausstehend' }, { ocrStatus: 'fehlgeschlagen' }, { ocrStatus: null }],
},
select: { id: true },
take: Math.min(Math.max(grenze, 1), 100),
})
for (const dokument of offene) {
await ocrNachholen(dokument.id)
}
return offene.length
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Aufbereitung der Sucheingabe - bewusst in einer eigenen, reinen Datei.
*
* `src/lib/dokumente/suche.ts` zieht die Datenbank hinter sich her; diese
* Regeln sollen sich aber ohne Postgres testen lassen. Wer sucht, importiert
* weiterhin aus suche.ts - dort wird von hier re-exportiert.
*/
/** Laenger sucht niemand von Hand, und die Datenbank dankt es. */
export const MAX_SUCHLAENGE = 200
/**
* Bereitet die Eingabe des Nutzers fuer `websearch_to_tsquery` auf.
*
* websearch_to_tsquery versteht die Schreibweise, die man aus Suchmaschinen
* kennt: einzelne Woerter, "Wortgruppen in Anfuehrungszeichen", `or` zum
* Verodern, ein vorangestelltes Minus zum Ausschliessen. Und es wirft bei
* keiner Eingabe - deshalb wird hier bewusst wenig veraendert.
*
* Was doch getan wird:
* - Steuerzeichen raus (kommen aus kopiertem OCR-Text)
* - Leerraum vereinheitlichen
* - Laenge begrenzen
* - ein einzelnes, offen gebliebenes Anfuehrungszeichen entfernen; sonst
* wuerde der halbe Rest zur Wortgruppe und der Nutzer wundert sich.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_SUCHLAENGE)
.trim()
if (sauber.length < 2) return null
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '').trim()
}
+10 -33
View File
@@ -1,6 +1,7 @@
import 'server-only'
import { Prisma, type DokumentArt, type DokumentRichtung, type PosteingangStatus } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff'
/**
* Volltextsuche ueber die Dokumente einer betreuten Person.
@@ -37,33 +38,10 @@ export type Suchtreffer = {
}
/**
* Bereitet die Eingabe des Nutzers auf.
*
* `websearch_to_tsquery` versteht die Schreibweise, die man aus
* Suchmaschinen kennt: Woerter, "Wortgruppen in Anfuehrungszeichen",
* `oder`, ein vorangestelltes Minus zum Ausschliessen. Deshalb wird hier
* bewusst wenig veraendert - nur Steuerzeichen und uebermaessige Laenge
* werden abgeraeumt.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
* Die Aufbereitung der Sucheingabe steht in einer eigenen, reinen Datei,
* damit sie sich ohne Datenbank testen laesst. Hier nur weitergereicht.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
if (sauber.length < 2) return null
// Ein einzelnes offenes Anfuehrungszeichen wuerde die halbe Anfrage zur
// Wortgruppe machen - lieber entfernen als den Nutzer raten lassen.
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '')
}
export { suchanfrageAufbereiten, MAX_SUCHLAENGE } from '@/lib/dokumente/suchbegriff'
export type Suchoptionen = {
grenze?: number
@@ -315,13 +293,12 @@ export async function dokumenteListe(
})
const nachId = new Map(datensaetze.map((d) => [d.id, d]))
const eintraege = seitenTreffer
.map((t) => {
const datensatz = nachId.get(t.id)
if (!datensatz) return null
return { ...zuEintrag(datensatz), ausschnitt: t.ausschnitt }
})
.filter((e): e is Listeneintrag => e !== null)
const eintraege: Listeneintrag[] = []
for (const treffer of seitenTreffer) {
const datensatz = nachId.get(treffer.id)
if (!datensatz) continue
eintraege.push({ ...zuEintrag(datensatz), ausschnitt: treffer.ausschnitt })
}
return { eintraege, gesamt: treffer.length }
}
+338
View File
@@ -0,0 +1,338 @@
import 'server-only'
import type { DokumentVerknuepfung } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Verknuepfungsziel } from '@/lib/validierung/dokumente'
/**
* Woran laesst sich ein Dokument haengen?
*
* Hier stehen die Listen fuer das Auswahlfeld und die Beschriftungen der
* bestehenden Verknuepfungen. Alles ist auf die betreuteId eingegrenzt -
* ein Dokument kann niemals an eine fremde Akte gebunden werden.
*/
export type Zielauswahl = {
ziel: Verknuepfungsziel
eintraege: { id: string; titel: string }[]
}
const OBERGRENZE = 100
export async function zieleDerAkte(betreuteId: string): Promise<Zielauswahl[]> {
const [
vorgaenge,
fristen,
buchungen,
posten,
immobilien,
vertraege,
protokolle,
ereignisse,
genehmigungen,
bescheide,
nachforderungen,
kontakte,
] = await Promise.all([
db.vorgang.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, status: true },
orderBy: [{ status: 'asc' }, { erstelltAm: 'desc' }],
take: OBERGRENZE,
}),
db.frist.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, datum: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.buchung.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, datum: true, betragCent: true, gegenstelle: true, richtung: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.immobilie.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
take: OBERGRENZE,
}),
db.kontaktprotokoll.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, datum: true, gespraechspartner: true, art: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.gesundheitsereignis.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, datum: true },
orderBy: { datum: 'desc' },
take: OBERGRENZE,
}),
db.genehmigung.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, titel: true, rechtsgrundlage: true },
orderBy: { erstelltAm: 'desc' },
take: OBERGRENZE,
}),
db.bescheid.findMany({
where: { antrag: { betreuteId }, ...nichtGeloescht },
select: { id: true, art: true, bescheidVom: true },
orderBy: { bescheidVom: 'desc' },
take: OBERGRENZE,
}),
db.nachforderung.findMany({
where: { antrag: { betreuteId }, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
orderBy: { erstelltAm: 'desc' },
take: OBERGRENZE,
}),
db.kontakt.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: { name: 'asc' },
take: OBERGRENZE,
}),
])
const alle: Zielauswahl[] = [
{
ziel: 'vorgang',
eintraege: vorgaenge.map((v) => ({
id: v.id,
titel: v.status === 'ERLEDIGT' ? `${v.titel} (erledigt)` : v.titel,
})),
},
{
ziel: 'frist',
eintraege: fristen.map((f) => ({ id: f.id, titel: `${f.titel} – ${formatDatum(f.datum)}` })),
},
{
ziel: 'buchung',
eintraege: buchungen.map((b) => ({
id: b.id,
titel: `${formatDatum(b.datum)} · ${b.richtung === 'AUSGANG' ? '−' : '+'}${formatCent(
b.betragCent,
)} · ${b.gegenstelle ?? 'ohne Gegenstelle'}`,
})),
},
{ ziel: 'vermoegensposten', eintraege: posten.map((p) => ({ id: p.id, titel: p.bezeichnung })) },
{ ziel: 'immobilie', eintraege: immobilien.map((i) => ({ id: i.id, titel: i.bezeichnung })) },
{
ziel: 'vertrag',
eintraege: vertraege.map((v) => ({
id: v.id,
titel: v.gesellschaft ? `${v.bezeichnung} – ${v.gesellschaft}` : v.bezeichnung,
})),
},
{
ziel: 'kontaktprotokoll',
eintraege: protokolle.map((p) => ({
id: p.id,
titel: `${formatDatum(p.datum)} · ${p.gespraechspartner ?? p.art}`,
})),
},
{
ziel: 'gesundheitsereignis',
eintraege: ereignisse.map((e) => ({ id: e.id, titel: `${formatDatum(e.datum)} · ${e.titel}` })),
},
{
ziel: 'genehmigung',
eintraege: genehmigungen.map((g) => ({ id: g.id, titel: `${g.titel} (${g.rechtsgrundlage})` })),
},
{
ziel: 'bescheid',
eintraege: bescheide.map((b) => ({ id: b.id, titel: `${b.art} vom ${formatDatum(b.bescheidVom)}` })),
},
{
ziel: 'nachforderung',
eintraege: nachforderungen.map((n) => ({ id: n.id, titel: n.bezeichnung })),
},
{
ziel: 'kontakt',
eintraege: kontakte.map((k) => ({
id: k.id,
titel: k.organisation ? `${k.name} – ${k.organisation}` : k.name,
})),
},
]
// Leere Kategorien gar nicht erst anbieten - ein Auswahlfeld ohne
// Eintraege ist nur eine Sackgasse.
return alle.filter((eintrag) => eintrag.eintraege.length > 0)
}
// -----------------------------------------------------------------------------
export type Verknuepfungsanzeige = {
id: string
ziel: Verknuepfungsziel
zielId: string
bezeichnung: string
bemerkung: string | null
href: string | null
}
type MitZielen = DokumentVerknuepfung & {
vorgang: { id: string; titel: string } | null
frist: { id: string; titel: string; datum: Date } | null
buchung: { id: string; datum: Date; betragCent: bigint; gegenstelle: string | null } | null
vermoegensposten: { id: string; bezeichnung: string } | null
immobilie: { id: string; bezeichnung: string } | null
vertrag: { id: string; bezeichnung: string } | null
kontaktprotokoll: { id: string; datum: Date; gespraechspartner: string | null } | null
gesundheitsereignis: { id: string; titel: string; datum: Date } | null
genehmigung: { id: string; titel: string } | null
nachforderung: { id: string; bezeichnung: string } | null
bescheid: { id: string; art: string; bescheidVom: Date } | null
kontakt: { id: string; name: string } | null
}
/** Die Auswahl, mit der eine Verknuepfung geladen werden muss. */
export const VERKNUEPFUNG_INCLUDE = {
vorgang: { select: { id: true, titel: true } },
frist: { select: { id: true, titel: true, datum: true } },
buchung: { select: { id: true, datum: true, betragCent: true, gegenstelle: true } },
vermoegensposten: { select: { id: true, bezeichnung: true } },
immobilie: { select: { id: true, bezeichnung: true } },
vertrag: { select: { id: true, bezeichnung: true } },
kontaktprotokoll: { select: { id: true, datum: true, gespraechspartner: true } },
gesundheitsereignis: { select: { id: true, titel: true, datum: true } },
genehmigung: { select: { id: true, titel: true } },
nachforderung: { select: { id: true, bezeichnung: true } },
bescheid: { select: { id: true, art: true, bescheidVom: true } },
kontakt: { select: { id: true, name: true } },
} as const
/** Macht aus einer Verknuepfungszeile eine anzeigbare Angabe. */
export function verknuepfungAnzeigen(zeile: MitZielen): Verknuepfungsanzeige | null {
const basis = { id: zeile.id, bemerkung: zeile.bemerkung }
if (zeile.vorgang) {
return {
...basis,
ziel: 'vorgang',
zielId: zeile.vorgang.id,
bezeichnung: zeile.vorgang.titel,
href: `/vorgaenge/${zeile.vorgang.id}`,
}
}
if (zeile.frist) {
return {
...basis,
ziel: 'frist',
zielId: zeile.frist.id,
bezeichnung: `${zeile.frist.titel} – ${formatDatum(zeile.frist.datum)}`,
href: `/fristen/${zeile.frist.id}`,
}
}
if (zeile.buchung) {
return {
...basis,
ziel: 'buchung',
zielId: zeile.buchung.id,
bezeichnung: `${formatDatum(zeile.buchung.datum)} · ${formatCent(zeile.buchung.betragCent)} · ${
zeile.buchung.gegenstelle ?? 'ohne Gegenstelle'
}`,
href: `/finanzen/buchungen/${zeile.buchung.id}`,
}
}
if (zeile.vermoegensposten) {
return {
...basis,
ziel: 'vermoegensposten',
zielId: zeile.vermoegensposten.id,
bezeichnung: zeile.vermoegensposten.bezeichnung,
href: `/finanzen/vermoegen/${zeile.vermoegensposten.id}`,
}
}
if (zeile.immobilie) {
return {
...basis,
ziel: 'immobilie',
zielId: zeile.immobilie.id,
bezeichnung: zeile.immobilie.bezeichnung,
href: `/immobilie/${zeile.immobilie.id}`,
}
}
if (zeile.vertrag) {
return {
...basis,
ziel: 'vertrag',
zielId: zeile.vertrag.id,
bezeichnung: zeile.vertrag.bezeichnung,
href: `/vertraege/${zeile.vertrag.id}`,
}
}
if (zeile.kontaktprotokoll) {
return {
...basis,
ziel: 'kontaktprotokoll',
zielId: zeile.kontaktprotokoll.id,
bezeichnung: `${formatDatum(zeile.kontaktprotokoll.datum)} · ${
zeile.kontaktprotokoll.gespraechspartner ?? 'Gesprächsnotiz'
}`,
href: `/kontakte/protokoll/${zeile.kontaktprotokoll.id}`,
}
}
if (zeile.gesundheitsereignis) {
return {
...basis,
ziel: 'gesundheitsereignis',
zielId: zeile.gesundheitsereignis.id,
bezeichnung: `${formatDatum(zeile.gesundheitsereignis.datum)} · ${zeile.gesundheitsereignis.titel}`,
href: `/pflege/${zeile.gesundheitsereignis.id}`,
}
}
if (zeile.genehmigung) {
return {
...basis,
ziel: 'genehmigung',
zielId: zeile.genehmigung.id,
bezeichnung: zeile.genehmigung.titel,
href: null,
}
}
if (zeile.nachforderung) {
return {
...basis,
ziel: 'nachforderung',
zielId: zeile.nachforderung.id,
bezeichnung: zeile.nachforderung.bezeichnung,
href: null,
}
}
if (zeile.bescheid) {
return {
...basis,
ziel: 'bescheid',
zielId: zeile.bescheid.id,
bezeichnung: `${zeile.bescheid.art} vom ${formatDatum(zeile.bescheid.bescheidVom)}`,
href: null,
}
}
if (zeile.kontakt) {
return {
...basis,
ziel: 'kontakt',
zielId: zeile.kontakt.id,
bezeichnung: zeile.kontakt.name,
href: `/kontakte/${zeile.kontakt.id}`,
}
}
return null
}
+19
View File
@@ -0,0 +1,19 @@
import 'server-only'
import { redirect } from 'next/navigation'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang, type ZugangsKontext } from '@/lib/auth/sitzung'
/**
* Einstieg fuer jede Seite des Finanzmoduls.
*
* Nimmt der Seite drei immer gleiche Handgriffe ab: angemeldeten
* Benutzer holen, die aktive betreute Person bestimmen und den Zugriff
* auf das Modul FINANZEN pruefen. Die Pruefung passiert hier serverseitig
* und noch einmal in jeder Server Action - eine Seite auszublenden ist
* kein Zugriffsschutz.
*/
export async function finanzKontext(): Promise<ZugangsKontext> {
const benutzer = await erfordereBenutzer('/finanzen')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
return erfordereZugang(aktive.id, 'FINANZEN', 'lesen')
}
+5
View File
@@ -26,6 +26,8 @@ export type WertstandEingabe = {
wertCent: bigint
wertquelle: string
nachweisDokumentId?: string | null
/** Titel des Nachweisdokuments - fuer den Belegverweis im Bericht. */
nachweisTitel?: string | null
bemerkung?: string | null
}
@@ -50,6 +52,7 @@ export type PostenZumStichtag = {
bewertetAm: Date
wertquelle: string
nachweisDokumentId: string | null
nachweisTitel: string | null
/** Wie alt ist der verwendete Wert am Stichtag? Null Tage = taggenau. */
alterTage: number
kontoId: string | null
@@ -136,6 +139,7 @@ export function vermoegenBerechnen(posten: PostenEingabe[], stichtag: Date): Ver
bewertetAm: alsTag(wert.stichtag),
wertquelle: wert.wertquelle,
nachweisDokumentId: wert.nachweisDokumentId ?? null,
nachweisTitel: wert.nachweisTitel ?? null,
alterTage: tageZwischen(wert.stichtag, tag),
kontoId: p.kontoId ?? null,
immobilieId: p.immobilieId ?? null,
@@ -205,6 +209,7 @@ export async function vermoegenZumStichtag(
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
nachweisTitel: null,
bemerkung: w.bemerkung,
})),
})),
+3 -2
View File
@@ -162,7 +162,8 @@ export function Berichtsseite({
mitUnterschrift?: boolean
/** Fuer breite Tabellen (Kassenbuch, Vermoegensuebersicht). */
querformat?: boolean
children: React.ReactNode
/** Optional: ein Deckblatt ohne Inhalt ist eine gueltige Seite. */
children?: React.ReactNode
}): React.JSX.Element {
const zeitraum = zeitraumText(kopf)
@@ -240,7 +241,7 @@ export function Berichtsdokument({
kopf: Berichtskopf
mitUnterschrift?: boolean
querformat?: boolean
children: React.ReactNode
children?: React.ReactNode
}): React.JSX.Element {
return (
<Document

Some files were not shown because too many files have changed in this diff Show More