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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user