docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

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

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

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

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

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

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

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
@@ -0,0 +1,111 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FolderArchive } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Meldung } from '@/components/berichte/meldung'
import { belegmappeAnlegen, type Aktionszustand } from '@/server/actions/berichte'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
<FolderArchive aria-hidden="true" />
{pending ? 'Belege werden zusammengelegt …' : 'Belegmappe erzeugen'}
</Button>
)
}
/**
* Auswahlmaske fuer die Belegmappe.
*
* Zwei Wege fuehren zu einer Mappe: ein Zeitraum (fuer die Rechnungslegung
* gegenueber dem Gericht) oder ein einzelner Vorgang (wenn der Bezirk
* fragt, was es mit einer bestimmten Sache auf sich hat). Das eigentliche
* Zusammenlegen der Belege macht das Dokumentenmodul - dort sitzt das
* Wissen darueber, welcher Beleg zu welcher Buchung gehoert.
*/
export function BelegmappeFormular({
betreuteId,
vorgaenge,
vorschlagVon,
vorschlagBis,
}: {
betreuteId: string
vorgaenge: { id: string; titel: string }[]
vorschlagVon: string
vorschlagBis: string
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(belegmappeAnlegen, {})
const [quelle, setQuelle] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<Feld
id="quelle"
beschriftung="Woraus soll die Mappe entstehen?"
erklaerung="Ein Zeitraum liefert die Belege zur Rechnungslegung, ein Vorgang die Belege zu einer einzelnen Sache."
pflicht
>
<Select
name="quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value as 'zeitraum' | 'vorgang')}
>
<option value="zeitraum">Alle Belege eines Zeitraums</option>
<option value="vorgang">Alle Belege zu einem Vorgang</option>
</Select>
</Feld>
{quelle === 'zeitraum' ? (
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="zeitraumVon" beschriftung="Von" fehler={zustand.felder?.zeitraumVon} pflicht>
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
</Feld>
<Feld id="zeitraumBis" beschriftung="Bis" fehler={zustand.felder?.zeitraumBis} pflicht>
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
</Feld>
</div>
) : (
<Feld
id="vorgangId"
beschriftung="Vorgang"
erklaerung="Es werden alle Dokumente aufgenommen, die mit diesem Vorgang verknüpft sind."
fehler={zustand.felder?.vorgangId}
pflicht
>
<Select name="vorgangId" required defaultValue="">
<option value="" disabled>
Bitte wählen
</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
)}
<Feld
id="titel"
beschriftung="Überschrift der Mappe"
erklaerung="Steht auf dem Deckblatt. Leer lassen genügt meistens."
fehler={zustand.felder?.titel}
>
<Input name="titel" placeholder="Belegmappe zur Rechnungslegung" maxLength={200} />
</Feld>
<Absenden />
</form>
)
}
+194
View File
@@ -0,0 +1,194 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FileDown, FileType2, RefreshCcw, Save } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Meldung } from '@/components/berichte/meldung'
import {
entwurfNeuBerechnen,
entwurfSpeichern,
jahresberichtAusgeben,
type Aktionszustand,
} from '@/server/actions/berichte'
/** Wie oft der Entwurf im Hintergrund gesichert wird, wenn sich etwas ändert. */
const AUTOSPEICHERN_MS = 60_000
function Knopf({
children,
variante = 'default',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'secondary'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Der Editor fuer den Jahresberichtsentwurf.
*
* Der wichtigste Satz dieser Seite steht oben im Kasten: Das ist ein
* Entwurf. Die Anwendung hat zusammengetragen, was sie weiss - aber sie
* war nicht dabei. Was das Gericht liest, muss der Betreuer verantworten,
* deshalb wird hier geschrieben und nicht nur bestaetigt.
*
* Zwischenspeichern geht jederzeit von Hand und laeuft zusaetzlich alle
* sechzig Sekunden von allein - ein verlorener Nachmittag Schreibarbeit
* waere die teuerste Art, Speicherplatz zu sparen.
*/
export function EntwurfEditor({
berichtId,
entwurf,
offeneStellen,
ergaenzenMarke,
hatPdf,
}: {
berichtId: string
entwurf: string
offeneStellen: number
ergaenzenMarke: string
hatPdf: boolean
}) {
const [text, setText] = React.useState(entwurf)
const [gesichert, setGesichert] = React.useState(entwurf)
const geaendert = text !== gesichert
const [speicherstand, speichern] = useActionState<Aktionszustand, FormData>(entwurfSpeichern, {})
const [ausgabestand, ausgeben] = useActionState<Aktionszustand, FormData>(jahresberichtAusgeben, {})
const [neustand, neuBerechnen] = useActionState<Aktionszustand, FormData>(entwurfNeuBerechnen, {})
const formular = React.useRef<HTMLFormElement>(null)
// Nach erfolgreichem Speichern gilt der aktuelle Text als gesichert.
React.useEffect(() => {
if (speicherstand.erfolg) setGesichert((alt) => (alt === text ? alt : text))
// Absichtlich nur am Erfolg haengend: Waehrend des Tippens soll nichts passieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speicherstand.erfolg])
// Regelmaessiges Sichern im Hintergrund.
React.useEffect(() => {
if (!geaendert) return
const uhr = setTimeout(() => formular.current?.requestSubmit(), AUTOSPEICHERN_MS)
return () => clearTimeout(uhr)
}, [geaendert, text])
// Warnung beim Verlassen mit ungesicherten Aenderungen.
React.useEffect(() => {
if (!geaendert) return
const beimVerlassen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault()
window.addEventListener('beforeunload', beimVerlassen)
return () => window.removeEventListener('beforeunload', beimVerlassen)
}, [geaendert])
const restlicheStellen = text.split(ergaenzenMarke).length - 1
return (
<div className="space-y-5">
<Alert variant="warnung">
<AlertTitle>Das ist ein Entwurf, kein fertiger Bericht</AlertTitle>
<AlertDescription>
<p>
Zusammengestellt wurde er aus deinen Kontaktprotokollen, den Gesundheitsereignissen und
den Vorgängen. Lies ihn durch, streiche, was nicht stimmt, und schreib in deinen eigenen
Worten, was nur du weißt. Abgegeben wird nichts von allein – du erzeugst das PDF, wenn
du zufrieden bist.
</p>
{restlicheStellen > 0 && (
<p className="mt-2 font-medium">
Im Text stehen noch {restlicheStellen}{' '}
{restlicheStellen === 1 ? 'Stelle' : 'Stellen'} mit „{ergaenzenMarke}“, die auf dich
warten.
</p>
)}
</AlertDescription>
</Alert>
<Meldung fehler={speicherstand.fehler} erfolg={speicherstand.erfolg} />
<Meldung fehler={ausgabestand.fehler} erfolg={ausgabestand.erfolg} />
<Meldung fehler={neustand.fehler} erfolg={neustand.erfolg} />
<form action={speichern} ref={formular} className="space-y-4">
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="entwurf" value={text} />
<Feld
id="entwurf"
beschriftung="Berichtstext"
erklaerung="Überschriften beginnen mit ihrer Nummer („3. Wünsche …“), Aufzählungen mit einem Punkt (•). Daran erkennt die Anwendung sie später beim Setzen von PDF und Word-Datei."
fehler={speicherstand.felder?.entwurf}
>
<Textarea
value={text}
onChange={(ereignis) => setText(ereignis.target.value)}
rows={30}
spellCheck
className="font-normal leading-relaxed"
/>
</Feld>
<div className="flex flex-wrap items-center gap-3">
<Knopf laeuftText="Wird gespeichert …">
<Save aria-hidden="true" />
Zwischenspeichern
</Knopf>
<p className="text-sm text-muted-foreground">
{geaendert
? 'Nicht gespeicherte Änderungen – es wird spätestens in einer Minute automatisch gesichert.'
: 'Alles gespeichert.'}
{offeneStellen > 0 && ' '}
</p>
</div>
</form>
<div className="flex flex-wrap gap-3 border-t pt-5">
<form action={ausgeben}>
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="format" value="pdf" />
<Knopf laeuftText="PDF wird gesetzt …">
<FileDown aria-hidden="true" />
{hatPdf ? 'PDF neu erzeugen' : 'PDF erzeugen'}
</Knopf>
</form>
<form action={ausgeben}>
<input type="hidden" name="berichtId" value={berichtId} />
<input type="hidden" name="format" value="docx" />
<Knopf variante="outline" laeuftText="Word-Datei wird gebaut …">
<FileType2 aria-hidden="true" />
Als Word-Datei
</Knopf>
</form>
<form
action={neuBerechnen}
onSubmit={(ereignis) => {
const sicher = window.confirm(
'Der Entwurf wird aus den aktuellen Daten neu aufgebaut. Alles, was du selbst ' +
'geschrieben hast, geht dabei verloren. Fortfahren?',
)
if (!sicher) ereignis.preventDefault()
}}
>
<input type="hidden" name="berichtId" value={berichtId} />
<Knopf variante="secondary" laeuftText="Wird neu aufgebaut …">
<RefreshCcw aria-hidden="true" />
Entwurf neu aufbauen
</Knopf>
</form>
</div>
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { FileText } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Meldung } from '@/components/berichte/meldung'
import { jahresberichtAnlegen, type Aktionszustand } from '@/server/actions/berichte'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
<FileText aria-hidden="true" />
{pending ? 'Entwurf wird gebaut …' : 'Entwurf erzeugen'}
</Button>
)
}
/**
* Startet einen Jahresbericht fuer einen Berichtszeitraum.
*
* Vorbelegt ist das laufende Betreuungsjahr - es beginnt am Tag der
* Bestellung, nicht am 1. Januar. Wer einen anderen Zeitraum braucht (etwa
* fuer eine Nachforderung des Gerichts), aendert die beiden Felder.
*/
export function JahresberichtFormular({
betreuteId,
vorschlagVon,
vorschlagBis,
}: {
betreuteId: string
/** ISO-Tag, z.B. "2025-10-01". */
vorschlagVon: string
vorschlagBis: string
}) {
const router = useRouter()
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(jahresberichtAnlegen, {})
React.useEffect(() => {
if (zustand.berichtId) router.push(`/berichte/jahresbericht/${zustand.berichtId}`)
}, [zustand.berichtId, router])
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="zeitraumVon"
beschriftung="Berichtszeitraum von"
erklaerung="In der Regel der Tag, an dem das laufende Betreuungsjahr begonnen hat."
fehler={zustand.felder?.zeitraumVon}
pflicht
>
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
</Feld>
<Feld
id="zeitraumBis"
beschriftung="Berichtszeitraum bis"
erklaerung="Meist der Tag vor dem nächsten Jahrestag der Bestellung."
fehler={zustand.felder?.zeitraumBis}
pflicht
>
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
</Feld>
</div>
<Absenden />
</form>
)
}
+46
View File
@@ -0,0 +1,46 @@
'use client'
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
/**
* Die Rueckmeldung einer Server Action - Fehler, Erfolg, Warnung.
*
* Eigene Komponente, weil sie in jedem Berichtsformular gebraucht wird und
* weil sie "aria-live" tragen muss: Wer mit Screenreader arbeitet, soll
* mitbekommen, dass etwas passiert ist, ohne die Seite abzusuchen.
*/
export function Meldung({
fehler,
erfolg,
warnung,
}: {
fehler?: string
erfolg?: string
warnung?: string
}) {
if (!fehler && !erfolg && !warnung) return null
return (
<div aria-live="polite" className="space-y-2">
{fehler && (
<Alert variant="fehler">
<AlertTriangle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
{warnung && (
<Alert variant="warnung">
<Info aria-hidden="true" />
<AlertDescription>{warnung}</AlertDescription>
</Alert>
)}
{erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{erfolg}</AlertDescription>
</Alert>
)}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { ArrowDownLeft, ArrowUpRight, CircleDot, FileText, Loader2, ScanLine } from 'lucide-react'
import type { DokumentArt, DokumentRichtung, PosteingangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import { POSTEINGANG_BESCHRIFTUNG } from '@/lib/validierung/dokumente'
import { cn } from '@/lib/utils'
/**
* Die kleinen Kennzeichen an einem Dokument.
*
* Ueberall gilt: Farbe ist nie die einzige Information. Jede Ampel traegt
* ihren Text mit, sonst waere die Liste fuer farbfehlsichtige Augen leer.
*/
export function ArtAbzeichen({ art, className }: { art: DokumentArt; className?: string }) {
return (
<Badge variant="outline" className={cn('gap-1', className)}>
<FileText className="size-3" aria-hidden="true" />
{ART_BESCHRIFTUNG[art]}
</Badge>
)
}
export function RichtungAbzeichen({ richtung }: { richtung: DokumentRichtung }) {
const Icon =
richtung === 'EINGANG' ? ArrowDownLeft : richtung === 'AUSGANG' ? ArrowUpRight : CircleDot
return (
<Badge variant="secondary" className="gap-1">
<Icon className="size-3" aria-hidden="true" />
{RICHTUNG_BESCHRIFTUNG[richtung]}
</Badge>
)
}
const POSTEINGANG_VARIANTE: Record<
PosteingangStatus,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
NEU: 'orange',
GESICHTET: 'gelb',
ZUGEORDNET: 'secondary',
BEANTWORTET: 'gruen',
}
export function PosteingangAbzeichen({ status }: { status: PosteingangStatus }) {
return <Badge variant={POSTEINGANG_VARIANTE[status]}>{POSTEINGANG_BESCHRIFTUNG[status]}</Badge>
}
/**
* Der Stand der Texterkennung. "nicht verfügbar" ist ausdruecklich kein
* Fehler: auf einem Windows-Rechner ohne Tesseract ist das der Normalfall,
* und das Dokument liegt trotzdem vollstaendig in der Akte.
*/
export function OcrAbzeichen({ status }: { status: string | null }) {
if (!status || status === 'fertig') return null
const texte: Record<string, { text: string; variante: 'gelb' | 'secondary' | 'orange' }> = {
ausstehend: { text: 'Texterkennung läuft gleich', variante: 'gelb' },
laeuft: { text: 'Texterkennung läuft', variante: 'gelb' },
fehlgeschlagen: { text: 'Texterkennung fehlgeschlagen', variante: 'orange' },
nicht_verfuegbar: { text: 'ohne Volltext', variante: 'secondary' },
}
const eintrag = texte[status]
if (!eintrag) return null
const Icon = status === 'laeuft' || status === 'ausstehend' ? Loader2 : ScanLine
return (
<Badge variant={eintrag.variante} className="gap-1">
<Icon className={cn('size-3', status === 'laeuft' && 'animate-spin')} aria-hidden="true" />
{eintrag.text}
</Badge>
)
}
/** Frei gewaehlte Schlagworte. */
export function TagAbzeichen({ name }: { name: string; farbe?: string }) {
return (
<Badge variant="outline" className="font-normal">
{name}
</Badge>
)
}
@@ -0,0 +1,128 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, BookOpen, Download, Loader2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { belegmappeAnfordern, type Mappenantwort } from '@/server/actions/dokumente'
/**
* Belegmappe erzeugen: alle Belege eines Vorgangs oder eines Zeitraums als
* **eine** chronologisch sortierte PDF mit Deckblatt, Inhaltsverzeichnis
* und durchgehender Seitenzahl.
*
* Genau das erwarten Gericht und Bezirk zur Rechnungslegung - nicht
* dreissig einzelne Anhaenge. Auf die Seitenzahl der Mappe kann sich der
* Bericht dann berufen ("Beleg 7, Seite 23").
*/
export function BelegmappeFormular({ vorgaenge }: { vorgaenge: { id: string; titel: string }[] }) {
const [zustand, aktion] = useActionState<Mappenantwort, FormData>(belegmappeAnfordern, {})
const [art, setArt] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
const jahr = new Date().getFullYear()
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.url && (
<Alert variant="erfolg">
<Download aria-hidden="true" />
<AlertDescription className="space-y-2">
<p>{zustand.hinweis}</p>
<a
href={zustand.url}
className="inline-flex font-medium text-primary underline underline-offset-4"
>
{zustand.dateiname ?? 'Belegmappe'} herunterladen
</a>
<p className="text-xs text-muted-foreground">
Die Adresse gilt zehn Minuten. Danach hier neu erzeugen.
</p>
</AlertDescription>
</Alert>
)}
{zustand.hinweise && zustand.hinweise.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
{zustand.hinweise.map((hinweis, i) => (
<li key={i}>{hinweis}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="mappe-art" beschriftung="Was soll in die Mappe?">
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as 'zeitraum' | 'vorgang')}
>
<option value="zeitraum">Alle Belege eines Zeitraums</option>
<option value="vorgang">Alle Belege eines Vorgangs</option>
</Select>
</Feld>
{art === 'vorgang' ? (
<Feld id="mappe-vorgang" beschriftung="Vorgang">
<Select name="vorgangId" disabled={vorgaenge.length === 0}>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
) : (
<>
<Feld id="mappe-von" beschriftung="Von" pflicht>
<Input name="von" type="date" defaultValue={`${jahr}-01-01`} required />
</Feld>
<Feld id="mappe-bis" beschriftung="Bis" pflicht>
<Input name="bis" type="date" defaultValue={`${jahr}-12-31`} required />
</Feld>
</>
)}
</div>
<MappeKnopf gesperrt={art === 'vorgang' && vorgaenge.length === 0} />
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" kurz />
</form>
)
}
function MappeKnopf({ gesperrt }: { gesperrt: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || gesperrt}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird zusammengestellt …
</>
) : (
<>
<BookOpen aria-hidden="true" />
Belegmappe erzeugen
</>
)}
</Button>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { AlertCircle, Download, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { dokumentLoeschen, vorschauUrl } from '@/server/actions/dokumente'
/**
* Datei ansehen, herunterladen, in den Papierkorb legen.
*
* Die Datei wird nie ueber eine dauerhafte Adresse ausgeliefert. Bei jedem
* Klick wird eine frische, fuenf Minuten gueltige Adresse geholt; der
* Server prueft beim Abruf zusaetzlich, ob der angemeldete Benutzer diese
* betreute Person betreut, und schreibt einen Eintrag ins Audit-Log.
* Ein weitergereichter Link oeffnet deshalb nichts.
*/
export function DateiAktionen({
dokumentId,
istBild,
hatDatei,
schreibrecht,
}: {
dokumentId: string
istBild: boolean
hatDatei: boolean
schreibrecht: boolean
}) {
const router = useRouter()
const [laeuft, setLaeuft] = React.useState<'oeffnen' | 'vorschau' | 'loeschen' | null>(null)
const [fehler, setFehler] = React.useState<string | null>(null)
const [vorschau, setVorschau] = React.useState<string | null>(null)
async function frischeUrl(): Promise<string | null> {
const antwort = await vorschauUrl(dokumentId)
if (antwort.fehler || !antwort.url) {
setFehler(antwort.fehler ?? 'Die Datei ließ sich nicht öffnen.')
return null
}
setFehler(null)
return antwort.url
}
if (!hatDatei) {
return (
<p className="text-sm text-muted-foreground">
Zu diesem Eintrag ist keine Datei hinterlegt – nur die Angaben in der Akte.
</p>
)
}
return (
<div className="space-y-3">
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={laeuft !== null}
onClick={async () => {
setLaeuft('oeffnen')
const url = await frischeUrl()
setLaeuft(null)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
>
{laeuft === 'oeffnen' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Download aria-hidden="true" />
)}
Datei öffnen
</Button>
{istBild &&
(vorschau ? (
<Button type="button" variant="outline" onClick={() => setVorschau(null)}>
<EyeOff aria-hidden="true" />
Vorschau schließen
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={laeuft !== null}
onClick={async () => {
setLaeuft('vorschau')
const url = await frischeUrl()
setLaeuft(null)
if (url) setVorschau(url)
}}
>
{laeuft === 'vorschau' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Eye aria-hidden="true" />
)}
Vorschau anzeigen
</Button>
))}
{schreibrecht && (
<Button
type="button"
variant="outline"
disabled={laeuft !== null}
onClick={async () => {
const sicher = window.confirm(
'Dieses Dokument in den Papierkorb legen? Du kannst es 30 Tage lang zurückholen.',
)
if (!sicher) return
setLaeuft('loeschen')
const ergebnis = await dokumentLoeschen(dokumentId)
setLaeuft(null)
if (ergebnis.fehler) setFehler(ergebnis.fehler)
else router.push('/dokumente')
}}
>
{laeuft === 'loeschen' ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Trash2 aria-hidden="true" />
)}
In den Papierkorb
</Button>
)}
</div>
{vorschau && (
<figure className="overflow-hidden rounded-lg border bg-muted/30">
{/* Bewusst ein einfaches <img>: die Datei wird als Anhang mit
nosniff ausgeliefert und kann so nichts ausfuehren. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={vorschau}
alt="Vorschau des hochgeladenen Dokuments"
className="max-h-[70vh] w-full object-contain"
onError={() =>
setFehler(
'Die Vorschau lässt sich in diesem Browser nicht anzeigen. Mit „Datei öffnen“ klappt es.',
)
}
/>
<figcaption className="border-t px-3 py-2 text-xs text-muted-foreground">
Vorschau – die Adresse gilt nur wenige Minuten.
</figcaption>
</figure>
)}
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import Link from 'next/link'
import { Link2, Paperclip } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatDatum } from '@/lib/utils'
import { formatBytes } from '@/lib/speicher/dateityp'
import type { Listeneintrag } from '@/lib/dokumente/suche'
import {
ArtAbzeichen,
OcrAbzeichen,
PosteingangAbzeichen,
RichtungAbzeichen,
TagAbzeichen,
} from '@/components/dokumente/abzeichen'
/**
* Die Dokumentenliste.
*
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
*/
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
return (
<>
{/* Handy */}
<ul className="space-y-3 md:hidden">
{eintraege.map((eintrag) => (
<li key={eintrag.id} className="rounded-lg border bg-card p-4">
<Link
href={`/dokumente/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
<p className="mt-1 text-sm text-muted-foreground">
{eintrag.absender ?? 'Ohne Absender'} · {formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
</p>
<Ausschnitt text={eintrag.ausschnitt} />
<div className="mt-2 flex flex-wrap gap-1.5">
<ArtAbzeichen art={eintrag.art} />
<RichtungAbzeichen richtung={eintrag.richtung} />
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
<OcrAbzeichen status={eintrag.ocrStatus} />
{eintrag.tags.map((tag) => (
<TagAbzeichen key={tag.id} name={tag.name} />
))}
</div>
</li>
))}
</ul>
{/* Tablet und Rechner */}
<div className="hidden overflow-x-auto rounded-lg border md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[38%]">Dokument</TableHead>
<TableHead>Absender</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Art</TableHead>
<TableHead>Posteingang</TableHead>
<TableHead className="text-right">Datei</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eintraege.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link
href={`/dokumente/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
{eintrag.aktenzeichen && (
<p className="text-xs text-muted-foreground">Az. {eintrag.aktenzeichen}</p>
)}
<Ausschnitt text={eintrag.ausschnitt} />
<div className="mt-1 flex flex-wrap gap-1">
{eintrag.tags.map((tag) => (
<TagAbzeichen key={tag.id} name={tag.name} />
))}
<OcrAbzeichen status={eintrag.ocrStatus} />
</div>
</TableCell>
<TableCell className="align-top">{eintrag.absender ?? '–'}</TableCell>
<TableCell className="whitespace-nowrap align-top tabular-nums">
{formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col gap-1">
<ArtAbzeichen art={eintrag.art} />
<RichtungAbzeichen richtung={eintrag.richtung} />
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col gap-1">
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
{eintrag.anzahlVerknuepfungen > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Link2 className="size-3" aria-hidden="true" />
{eintrag.anzahlVerknuepfungen} verknüpft
</span>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-right align-top text-xs text-muted-foreground">
{eintrag.dateiname ? (
<span className="inline-flex items-center gap-1">
<Paperclip className="size-3" aria-hidden="true" />
{formatBytes(eintrag.groesseBytes ?? 0)}
{eintrag.seitenzahl ? ` · ${eintrag.seitenzahl} S.` : ''}
</span>
) : (
'ohne Datei'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)
}
/**
* Die Fundstelle aus der Volltextsuche. Postgres liefert sie mit
* <mark>-Auszeichnung; hier wird sie kontrolliert wieder zusammengesetzt,
* statt fremdes HTML in die Seite zu lassen.
*/
function Ausschnitt({ text }: { text?: string }) {
if (!text) return null
const teile = text.split(/(<mark>|<\/mark>)/)
let hervorgehoben = false
return (
<p className="mt-1 text-sm text-muted-foreground">
{teile.map((teil, i) => {
if (teil === '<mark>') {
hervorgehoben = true
return null
}
if (teil === '</mark>') {
hervorgehoben = false
return null
}
return hervorgehoben ? (
<mark key={i} className="rounded bg-ampel-gelb/25 px-0.5 text-foreground">
{teil}
</mark>
) : (
<span key={i}>{teil}</span>
)
})}
</p>
)
}
@@ -0,0 +1,365 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CalendarClock, CheckCircle2, Loader2, ScanText, Sparkles } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import { DOKUMENT_ARTEN } from '@/lib/validierung/dokumente'
import {
dokumentSpeichern,
fristUebernehmen,
vorschlaegeLesen,
type Aktionszustand,
type Vorschlagsantwort,
} from '@/server/actions/dokumente'
/**
* Die Erkennungsvorschlaege zu einem frisch hochgeladenen Dokument.
*
* **Nichts hiervon wird automatisch uebernommen.** Jeder Wert steht als
* Vorschlag da, mit der Textstelle, aus der er stammt, und wird erst durch
* einen Klick verbindlich. Bei Fristen ist das keine Bequemlichkeit,
* sondern eine Sicherheitsentscheidung: eine falsch gerechnete Frist, auf
* die man sich verlaesst, ist schlimmer als gar keine.
*
* Die Texterkennung laeuft im Hintergrund weiter, waehrend diese Ansicht
* schon steht - deshalb wird ein paar Mal nachgefragt, bis ein Ergebnis da
* ist. Kommt keins (kein Tesseract auf dem Rechner), sagt die Ansicht das
* und bietet die Eingabe von Hand an.
*/
export function ErkennungPanel({
dokumentId,
vorgaenge,
}: {
dokumentId: string
vorgaenge: { id: string; titel: string }[]
}) {
const [antwort, setAntwort] = React.useState<Vorschlagsantwort | null>(null)
const [laeuft, setLaeuft] = React.useState(true)
React.useEffect(() => {
let abgebrochen = false
let versuche = 0
async function nachfragen() {
const ergebnis = await vorschlaegeLesen(dokumentId)
if (abgebrochen) return
setAntwort(ergebnis)
versuche += 1
const nochUnterwegs =
!ergebnis.fehler &&
(ergebnis.ocrStatus === 'ausstehend' || ergebnis.ocrStatus === 'laeuft') &&
versuche < 15
if (nochUnterwegs) {
// Erst schnell, dann geduldiger - ein Scan mit zwanzig Seiten
// braucht seine Zeit.
window.setTimeout(nachfragen, Math.min(1500 + versuche * 750, 6000))
} else {
setLaeuft(false)
}
}
void nachfragen()
return () => {
abgebrochen = true
}
}, [dokumentId])
if (laeuft && !antwort?.vorschlaege) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Der Text wird gelesen. Du kannst hier warten oder die Angaben gleich von Hand eintragen.
</div>
)
}
if (antwort?.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{antwort.fehler}</AlertDescription>
</Alert>
)
}
if (!antwort?.vorschlaege) {
return (
<Alert variant="hinweis">
<ScanText aria-hidden="true" />
<AlertDescription>
{antwort?.ocrStatus === 'nicht_verfuegbar'
? 'Auf diesem Rechner ist keine Texterkennung installiert (Tesseract). Das Dokument ist gespeichert – trage Absender, Datum und Aktenzeichen bitte von Hand ein.'
: 'Aus diesem Dokument ließ sich kein Text lesen. Das kommt bei schlecht belichteten Fotos vor. Trage die Angaben bitte von Hand ein.'}
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-6">
<MetadatenUebernehmen
dokumentId={dokumentId}
vorschlaege={antwort.vorschlaege}
/>
<Fristvorschlaege
dokumentId={dokumentId}
fristen={antwort.vorschlaege.fristen}
vorgaenge={vorgaenge}
/>
</div>
)
}
// -----------------------------------------------------------------------------
function MetadatenUebernehmen({
dokumentId,
vorschlaege,
}: {
dokumentId: string
vorschlaege: NonNullable<Vorschlagsantwort['vorschlaege']>
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
return (
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
{/* Wird durch die Zuordnung ohnehin gesetzt - hier gesichtet. */}
<input type="hidden" name="posteingangStatus" value="GESICHTET" />
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<h2 className="font-semibold">Das haben wir im Schreiben gelesen</h2>
<p className="text-sm text-muted-foreground">
Bitte prüfen und ändern, wo es nicht stimmt. Erst mit „Übernehmen“ wird es gespeichert.
</p>
</div>
</div>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.hinweis && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="v-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" defaultValue={vorschlaege.titel ?? ''} maxLength={200} required />
</Feld>
<Feld id="v-absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
<Input name="absender" defaultValue={vorschlaege.absender ?? ''} maxLength={200} />
</Feld>
<Feld id="v-art" beschriftung="Art">
<Select name="art" defaultValue={vorschlaege.art ?? 'SONSTIGES'}>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="v-richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={vorschlaege.richtung ?? 'EINGANG'}>
<option value="EINGANG">Eingang</option>
<option value="AUSGANG">Ausgang</option>
<option value="INTERN">Intern</option>
</Select>
</Feld>
<Feld
id="v-schreibenVom"
beschriftung="Datum des Schreibens"
erklaerung="Das Datum, das oben auf dem Brief steht."
fehler={zustand.felder?.schreibenVom}
>
<Input name="schreibenVom" type="date" defaultValue={vorschlaege.schreibenVom ?? ''} />
</Feld>
<Feld id="v-aktenzeichen" beschriftung="Aktenzeichen / Nummer">
<Input name="aktenzeichen" defaultValue={vorschlaege.aktenzeichen ?? ''} maxLength={100} />
</Feld>
</div>
<UebernehmenKnopf />
</form>
)
}
function UebernehmenKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CheckCircle2 aria-hidden="true" />}
Vorschläge übernehmen
</Button>
)
}
// -----------------------------------------------------------------------------
function Fristvorschlaege({
dokumentId,
fristen,
vorgaenge,
}: {
dokumentId: string
fristen: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen']
vorgaenge: { id: string; titel: string }[]
}) {
if (fristen.length === 0) return null
return (
<div className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex items-start gap-2">
<CalendarClock className="mt-0.5 size-5 shrink-0 text-ampel-orange" aria-hidden="true" />
<div>
<h2 className="font-semibold">
{fristen.length === 1 ? 'Mögliche Frist im Text' : `${fristen.length} mögliche Fristen im Text`}
</h2>
<p className="text-sm text-muted-foreground">
Nichts davon ist angelegt. Prüfe die Textstelle und übernimm nur, was wirklich eine
Frist ist.
</p>
</div>
</div>
{fristen.map((frist, i) => (
<Fristkarte key={i} dokumentId={dokumentId} frist={frist} vorgaenge={vorgaenge} />
))}
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 70 VwGO" kurz />
</div>
)
}
function Fristkarte({
dokumentId,
frist,
vorgaenge,
}: {
dokumentId: string
frist: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen'][number]
vorgaenge: { id: string; titel: string }[]
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(fristUebernehmen, {})
const [offen, setOffen] = React.useState(false)
if (zustand.hinweis) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)
}
return (
<div className="rounded-md border p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{frist.titel}</p>
<p className="text-sm text-muted-foreground">{frist.begruendung}</p>
</div>
{frist.rechtsbehelf && <Badge variant="rot">Rechtsbehelfsfrist</Badge>}
</div>
<blockquote className="mt-2 border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
{frist.textstelle}
</blockquote>
{zustand.fehler && (
<p className="mt-2 text-sm font-medium text-destructive">{zustand.fehler}</p>
)}
{!offen ? (
<Button type="button" variant="outline" className="mt-3" onClick={() => setOffen(true)}>
<CalendarClock aria-hidden="true" />
Als Frist übernehmen
</Button>
) : (
<form action={aktion} className="mt-3 space-y-3" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
<input type="hidden" name="beschreibung" value={frist.begruendung} />
<input type="hidden" name="typ" value={frist.rechtsbehelf ? 'GESETZLICH' : 'BEHOERDLICH'} />
{frist.rechtsbehelf && <input type="hidden" name="ausschlussfrist" value="ja" />}
<div className="grid gap-3 sm:grid-cols-2">
<Feld id={`f-titel-${dokumentId}`} beschriftung="Titel der Frist" pflicht>
<Input name="titel" defaultValue={frist.titel} maxLength={200} required />
</Feld>
<Feld
id={`f-datum-${dokumentId}`}
beschriftung="Fällig am"
erklaerung="Bitte gegen das Schreiben prüfen. Bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum."
pflicht
>
<Input name="datum" type="date" defaultValue={frist.datum ?? ''} required />
</Feld>
{vorgaenge.length > 0 && (
<Feld id={`f-vorgang-${dokumentId}`} beschriftung="Zu welchem Vorgang?">
<Select name="vorgangId" defaultValue="">
<option value="">Keinem Vorgang zuordnen</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
)}
<Feld id={`f-grundlage-${dokumentId}`} beschriftung="Rechtsgrundlage (optional)">
<Input
name="rechtsgrundlage"
defaultValue={frist.rechtsbehelf ? '§ 84 SGG' : ''}
maxLength={120}
/>
</Feld>
</div>
<div className="flex gap-2">
<FristKnopf />
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)
}
function FristKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CalendarClock aria-hidden="true" />}
Frist anlegen
</Button>
)
}
+134
View File
@@ -0,0 +1,134 @@
import Link from 'next/link'
import { Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
DOKUMENT_ARTEN,
DOKUMENT_RICHTUNGEN,
POSTEINGANG_BESCHRIFTUNG,
POSTEINGANG_STUFEN,
} from '@/lib/validierung/dokumente'
/**
* Suche und Filter ueber der Dokumentenliste.
*
* Bewusst ein schlichtes GET-Formular ohne JavaScript: die Filter stehen
* damit in der Adresszeile, lassen sich als Lesezeichen ablegen ("alle
* offenen Bescheide vom Bezirk") und funktionieren auch, wenn im Zug das
* Netz wackelt.
*/
export function FilterLeiste({
ziel = '/dokumente',
werte,
tags,
mitPosteingang = true,
}: {
ziel?: string
werte: {
suche?: string | null
art?: string | null
richtung?: string | null
status?: string | null
von?: string | null
bis?: string | null
tag?: string | null
}
tags: { id: string; name: string }[]
mitPosteingang?: boolean
}) {
const etwasGesetzt = Object.values(werte).some((wert) => wert)
return (
<form method="get" action={ziel} className="space-y-4 rounded-lg border bg-card p-4">
<Feld
id="suche"
beschriftung="Volltextsuche"
erklaerung="Sucht in Titel, Absender, Aktenzeichen und im erkannten Text der Dokumente. Mehrere Wörter suchen alle; „Wortgruppe“ in Anführungszeichen sucht genau so; ein Minus davor schließt aus."
>
<Input
name="suche"
type="search"
defaultValue={werte.suche ?? ''}
placeholder="z. B. Hilfe zur Pflege"
autoComplete="off"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Feld id="art" beschriftung="Art">
<Select name="art" defaultValue={werte.art ?? ''}>
<option value="">Alle Arten</option>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={werte.richtung ?? ''}>
<option value="">Ein- und Ausgang</option>
{DOKUMENT_RICHTUNGEN.map((richtung) => (
<option key={richtung} value={richtung}>
{RICHTUNG_BESCHRIFTUNG[richtung]}
</option>
))}
</Select>
</Feld>
{mitPosteingang && (
<Feld id="status" beschriftung="Posteingang">
<Select name="status" defaultValue={werte.status ?? ''}>
<option value="">Alle Stufen</option>
{POSTEINGANG_STUFEN.map((stufe) => (
<option key={stufe} value={stufe}>
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</option>
))}
</Select>
</Feld>
)}
<Feld id="von" beschriftung="Zeitraum von" erklaerung="Nach Eingangsdatum.">
<Input name="von" type="date" defaultValue={werte.von ?? ''} />
</Feld>
<Feld id="bis" beschriftung="Zeitraum bis">
<Input name="bis" type="date" defaultValue={werte.bis ?? ''} />
</Feld>
{tags.length > 0 && (
<Feld id="tag" beschriftung="Schlagwort">
<Select name="tag" defaultValue={werte.tag ?? ''}>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</Select>
</Feld>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
<Search aria-hidden="true" />
Suchen
</Button>
{etwasGesetzt && (
<Button type="button" variant="ghost" asChild>
<Link href={ziel}>
<X aria-hidden="true" />
Filter zurücksetzen
</Link>
</Button>
)}
</div>
</form>
)
}
@@ -0,0 +1,240 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2, Save } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
import {
DOKUMENT_ARTEN,
DOKUMENT_RICHTUNGEN,
POSTEINGANG_BESCHRIFTUNG,
POSTEINGANG_STUFEN,
} from '@/lib/validierung/dokumente'
import { dokumentSpeichern, type Aktionszustand } from '@/server/actions/dokumente'
export type MetadatenWerte = {
id: string
titel: string
art: string
richtung: string
schreibenVom: string
eingangAm: string
versandAm: string
absender: string
empfaenger: string
aktenzeichen: string
ablageortPapier: string
wiedervorlageAm: string
ablaufAm: string
posteingangStatus: string
notizen: string
tagIds: string[]
}
/**
* Die Metadaten eines Dokuments.
*
* Das Feld, das hier am leichtesten uebersehen wird und trotzdem das
* wichtigste ist: **der Ablageort des Originals**. Diese Anwendung ersetzt
* den Leitz-Ordner nicht - sie sagt, in welchem er liegt. Wenn das Gericht
* das Original sehen will, muss man es finden.
*/
export function MetadatenFormular({
werte,
tags,
schreibrecht,
}: {
werte: MetadatenWerte
tags: { id: string; name: string }[]
schreibrecht: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
return (
<form action={aktion} className="space-y-6" noValidate>
<input type="hidden" name="dokumentId" value={werte.id} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.hinweis && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis}</AlertDescription>
</Alert>
)}
<fieldset disabled={!schreibrecht} className="space-y-6">
<Feldgruppe titel="Das Schreiben">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="Woran erkennst du dieses Dokument in einem Jahr wieder?"
fehler={zustand.felder?.titel}
pflicht
className="sm:col-span-2"
>
<Input name="titel" defaultValue={werte.titel} maxLength={200} required />
</Feld>
<Feld id="art" beschriftung="Art">
<Select name="art" defaultValue={werte.art}>
{DOKUMENT_ARTEN.map((art) => (
<option key={art} value={art}>
{ART_BESCHRIFTUNG[art]}
</option>
))}
</Select>
</Feld>
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={werte.richtung}>
{DOKUMENT_RICHTUNGEN.map((richtung) => (
<option key={richtung} value={richtung}>
{RICHTUNG_BESCHRIFTUNG[richtung]}
</option>
))}
</Select>
</Feld>
<Feld id="absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
<Input name="absender" defaultValue={werte.absender} maxLength={200} />
</Feld>
<Feld id="empfaenger" beschriftung="Empfänger">
<Input name="empfaenger" defaultValue={werte.empfaenger} maxLength={200} />
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen / Nummer"
erklaerung="Gerichtsaktenzeichen, Kundennummer, Versicherungsschein-Nr. – was das Schreiben eben trägt."
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} maxLength={100} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Daten"
beschreibung="Für Rechtsbehelfsfristen zählt der Eingang, nicht das Datum auf dem Brief."
>
<Feld id="schreibenVom" beschriftung="Datum des Schreibens">
<Input name="schreibenVom" type="date" defaultValue={werte.schreibenVom} />
</Feld>
<Feld
id="eingangAm"
beschriftung="Eingegangen am"
fehler={zustand.felder?.eingangAm}
erklaerung="Der Tag, an dem der Brief bei dir im Kasten lag."
>
<Input name="eingangAm" type="date" defaultValue={werte.eingangAm} />
</Feld>
<Feld id="versandAm" beschriftung="Versandt am" erklaerung="Nur bei ausgehender Post.">
<Input name="versandAm" type="date" defaultValue={werte.versandAm} />
</Feld>
<Feld
id="wiedervorlageAm"
beschriftung="Wiedervorlage am"
erklaerung="Wann willst du dieses Blatt wiedersehen?"
>
<Input name="wiedervorlageAm" type="date" defaultValue={werte.wiedervorlageAm} />
</Feld>
<Feld
id="ablaufAm"
beschriftung="Gültig bis"
erklaerung="z. B. Ablauf des Betreuerausweises oder der Zuzahlungsbefreiung."
>
<Input name="ablaufAm" type="date" defaultValue={werte.ablaufAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Ablage">
<Feld
id="ablageortPapier"
beschriftung="Ablageort des Originals"
erklaerung="Ordner und Register, in dem das Blatt in Papier liegt. Papier bleibt Papier – und das Gericht fragt danach."
className="sm:col-span-2"
>
<Input
name="ablageortPapier"
defaultValue={werte.ablageortPapier}
maxLength={150}
placeholder="Ordner 2, Register Bezirk Oberfranken"
/>
</Feld>
<Feld id="posteingangStatus" beschriftung="Stand im Posteingang">
<Select name="posteingangStatus" defaultValue={werte.posteingangStatus}>
{POSTEINGANG_STUFEN.map((stufe) => (
<option key={stufe} value={stufe}>
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</option>
))}
</Select>
</Feld>
<Feld
id="neueTags"
beschriftung="Neue Schlagworte"
erklaerung="Mit Komma getrennt, z. B. „Heimkosten, wichtig“."
>
<Input name="neueTags" maxLength={200} placeholder="Heimkosten, wichtig" />
</Feld>
{tags.length > 0 && (
<fieldset className="sm:col-span-2">
<legend className="mb-2 text-sm font-medium">Vorhandene Schlagworte</legend>
<div className="flex flex-wrap gap-3">
{tags.map((tag) => (
<label key={tag.id} className="flex min-h-11 items-center gap-2 text-sm">
<Checkbox
name="tagIds"
value={tag.id}
defaultChecked={werte.tagIds.includes(tag.id)}
/>
{tag.name}
</label>
))}
</div>
</fieldset>
)}
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Werden verschlüsselt gespeichert."
className="sm:col-span-2"
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
</Feldgruppe>
</fieldset>
{schreibrecht && <SpeichernKnopf />}
</form>
)
}
function SpeichernKnopf() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
Speichern
</Button>
)
}
@@ -0,0 +1,75 @@
'use client'
import * as React from 'react'
import { Check, Loader2 } from 'lucide-react'
import type { PosteingangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { posteingangSetzen } from '@/server/actions/dokumente'
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
import { cn } from '@/lib/utils'
/**
* Das Posteingangsbuch als Kette: NEU -> GESICHTET -> ZUGEORDNET ->
* BEANTWORTET.
*
* Diese vier Stufen sind kein Selbstzweck. Sie beantworten die Frage, die
* man sich am Kuechentisch mit dem Poststapel stellt: Was habe ich schon
* gelesen, was noch nicht, und was wartet noch auf eine Antwort? Nichts
* darf durchrutschen - dafuer gibt es diese Zeile.
*/
export function PosteingangStufen({
dokumentId,
status,
schreibrecht,
kompakt,
}: {
dokumentId: string
status: PosteingangStatus
schreibrecht: boolean
kompakt?: boolean
}) {
const [laeuft, setLaeuft] = React.useState<string | null>(null)
const [aktuell, setAktuell] = React.useState<PosteingangStatus>(status)
React.useEffect(() => setAktuell(status), [status])
const erreicht = POSTEINGANG_STUFEN.indexOf(aktuell)
return (
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label="Stand im Posteingang"
>
{POSTEINGANG_STUFEN.map((stufe, i) => {
const istAktuell = stufe === aktuell
const istErledigt = i < erreicht
return (
<Button
key={stufe}
type="button"
size={kompakt ? 'sm' : 'default'}
variant={istAktuell ? 'default' : istErledigt ? 'secondary' : 'outline'}
disabled={!schreibrecht || laeuft !== null || istAktuell}
aria-current={istAktuell ? 'step' : undefined}
className={cn(!schreibrecht && 'pointer-events-none')}
onClick={async () => {
setLaeuft(stufe)
const ergebnis = await posteingangSetzen(dokumentId, stufe)
if (!ergebnis.fehler) setAktuell(stufe)
setLaeuft(null)
}}
>
{laeuft === stufe ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : istErledigt ? (
<Check aria-hidden="true" />
) : null}
{POSTEINGANG_BESCHRIFTUNG[stufe]}
</Button>
)
})}
</div>
)
}
@@ -0,0 +1,298 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
AlertCircle,
Camera,
CheckCircle2,
FileUp,
Loader2,
Trash2,
Upload,
} from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { ACCEPT_ATTRIBUT, formatBytes } from '@/lib/speicher/dateityp'
import { dokumenteHochladen, type Aktionszustand } from '@/server/actions/dokumente'
import { ErkennungPanel } from '@/components/dokumente/erkennung-panel'
import { cn } from '@/lib/utils'
/**
* Dokumente aufnehmen: per Drag&Drop, per Dateiauswahl oder direkt mit der
* Handykamera.
*
* Das Ziel dieser Seite in einem Satz: **Foto hochladen, in zwei Klicks
* einem Vorgang zuordnen, daraus eine Frist erzeugen.** Alles andere ist
* Beiwerk. Deshalb sind Titel, Art und Absender optional - was der Brief
* hergibt, schlaegt die Erkennung anschliessend selbst vor.
*/
export function UploadFormular({
vorgaenge,
vorgangVorgewaehlt,
kameraZuerst,
}: {
vorgaenge: { id: string; titel: string }[]
vorgangVorgewaehlt?: string | null
/** Aufruf aus der Schnellerfassung "Beleg fotografieren". */
kameraZuerst?: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumenteHochladen, {})
const [dateien, setDateien] = React.useState<File[]>([])
const [ueberzogen, setUeberzogen] = React.useState(false)
const dateiFeld = React.useRef<HTMLInputElement>(null)
const kameraFeld = React.useRef<HTMLInputElement>(null)
/**
* Die ausgewaehlten Dateien werden in einem eigenen Zustand gehalten und
* vor dem Absenden in das versteckte Feld gespiegelt. Nur so lassen sich
* Dateiauswahl, Kamera und Drag&Drop zu einem Stapel zusammenfuehren.
*/
const uebernehmen = React.useCallback((neue: FileList | null) => {
if (!neue || neue.length === 0) return
setDateien((bisher) => {
const zusammen = [...bisher]
for (const datei of Array.from(neue)) {
const schonDa = zusammen.some(
(d) => d.name === datei.name && d.size === datei.size && d.lastModified === datei.lastModified,
)
if (!schonDa) zusammen.push(datei)
}
return zusammen.slice(0, 30)
})
}, [])
React.useEffect(() => {
if (!dateiFeld.current) return
const behaelter = new DataTransfer()
for (const datei of dateien) behaelter.items.add(datei)
dateiFeld.current.files = behaelter.files
}, [dateien])
// Nach einem erfolgreichen Stapel die Auswahl leeren.
React.useEffect(() => {
if (zustand.ergebnisse && zustand.ergebnisse.some((e) => e.dokumentId)) {
setDateien([])
}
}, [zustand.ergebnisse])
const gesamt = dateien.reduce((summe, datei) => summe + datei.size, 0)
const einzelnHochgeladen =
zustand.ergebnisse?.filter((e) => e.dokumentId).length === 1
? zustand.ergebnisse.find((e) => e.dokumentId)!.dokumentId!
: null
return (
<div className="space-y-6">
<form action={aktion} className="space-y-6" noValidate>
<input
ref={dateiFeld}
type="file"
name="dateien"
multiple
accept={ACCEPT_ATTRIBUT}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(ereignis) => uebernehmen(ereignis.target.files)}
/>
{/* Getrenntes Feld fuer die Kamera: capture darf nicht am Sammelfeld
haengen, sonst bietet das Handy keine Dateiauswahl mehr an. */}
<input
ref={kameraFeld}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(ereignis) => {
uebernehmen(ereignis.target.files)
ereignis.target.value = ''
}}
/>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div
onDragOver={(ereignis) => {
ereignis.preventDefault()
setUeberzogen(true)
}}
onDragLeave={() => setUeberzogen(false)}
onDrop={(ereignis) => {
ereignis.preventDefault()
setUeberzogen(false)
uebernehmen(ereignis.dataTransfer.files)
}}
className={cn(
'rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-10',
ueberzogen ? 'border-primary bg-primary/5' : 'border-border',
)}
>
<FileUp className="mx-auto mb-3 size-8 text-muted-foreground" aria-hidden="true" />
<p className="font-medium">Dokumente hierher ziehen</p>
<p className="mt-1 text-sm text-muted-foreground">
PDF, JPEG, PNG, WEBP, TIFF oder HEIC. Mehrere auf einmal sind ausdrücklich erwünscht.
</p>
<div className="mt-5 flex flex-col justify-center gap-2 sm:flex-row">
<Button
type="button"
variant={kameraZuerst ? 'default' : 'outline'}
size="lg"
onClick={() => kameraFeld.current?.click()}
>
<Camera aria-hidden="true" />
Foto aufnehmen
</Button>
<Button
type="button"
variant={kameraZuerst ? 'outline' : 'default'}
size="lg"
onClick={() => dateiFeld.current?.click()}
>
<Upload aria-hidden="true" />
Dateien auswählen
</Button>
</div>
</div>
{dateien.length > 0 && (
<div className="rounded-lg border">
<div className="flex items-center justify-between border-b px-4 py-2.5 text-sm">
<span className="font-medium">
{dateien.length} {dateien.length === 1 ? 'Datei' : 'Dateien'} · {formatBytes(gesamt)}
</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setDateien([])}>
Auswahl leeren
</Button>
</div>
<ul className="divide-y">
{dateien.map((datei, i) => (
<li key={`${datei.name}-${i}`} className="flex items-center gap-3 px-4 py-2.5">
<span className="min-w-0 flex-1 truncate text-sm">{datei.name}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatBytes(datei.size)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setDateien((bisher) => bisher.filter((_, index) => index !== i))}
aria-label={`${datei.name} aus der Auswahl entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="Nur bei einer einzelnen Datei. Leer lassen ist in Ordnung – die Erkennung schlägt gleich einen Titel vor."
fehler={zustand.felder?.titel}
>
<Input name="titel" maxLength={200} />
</Feld>
<Feld
id="eingangAm"
beschriftung="Eingegangen am"
erklaerung="Maßgeblich für Rechtsbehelfsfristen – nicht das Datum auf dem Brief. Leer = heute."
fehler={zustand.felder?.eingangAm}
>
<Input name="eingangAm" type="date" />
</Feld>
<Feld id="vorgangId" beschriftung="Direkt einem Vorgang zuordnen">
<Select name="vorgangId" defaultValue={vorgangVorgewaehlt ?? ''}>
<option value="">Später zuordnen</option>
{vorgaenge.map((vorgang) => (
<option key={vorgang.id} value={vorgang.id}>
{vorgang.titel}
</option>
))}
</Select>
</Feld>
<Feld
id="ablageortPapier"
beschriftung="Ablageort des Originals"
erklaerung="Wo liegt das Blatt in Papier? z. B. „Ordner 2, Register Bezirk“. Papier bleibt Papier."
>
<Input name="ablageortPapier" maxLength={150} placeholder="Ordner 2, Register Bezirk" />
</Feld>
</div>
<Absendeknopf anzahl={dateien.length} />
</form>
{zustand.ergebnisse && zustand.ergebnisse.length > 0 && (
<div className="space-y-3">
<Alert variant={zustand.ergebnisse.every((e) => e.dokumentId) ? 'erfolg' : 'warnung'}>
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.hinweis ?? 'Verarbeitet.'}</AlertDescription>
</Alert>
<ul className="divide-y rounded-lg border">
{zustand.ergebnisse.map((ergebnis, i) => (
<li key={i} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
<span className="min-w-0 flex-1 truncate">{ergebnis.dateiname}</span>
{ergebnis.dokumentId ? (
<Link
href={`/dokumente/${ergebnis.dokumentId}`}
className="shrink-0 font-medium text-primary hover:underline"
>
Öffnen
</Link>
) : (
<span className="text-destructive">{ergebnis.fehler}</span>
)}
</li>
))}
</ul>
</div>
)}
{einzelnHochgeladen && (
<ErkennungPanel dokumentId={einzelnHochgeladen} vorgaenge={vorgaenge} />
)}
</div>
)
}
function Absendeknopf({ anzahl }: { anzahl: number }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={pending || anzahl === 0}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird übernommen …
</>
) : (
<>
<Upload aria-hidden="true" />
{anzahl <= 1 ? 'In die Akte übernehmen' : `${anzahl} Dokumente übernehmen`}
</>
)}
</Button>
)
}
+178
View File
@@ -0,0 +1,178 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Link2, Link2Off, Loader2, Plus } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { ZIEL_BESCHRIFTUNG, type Verknuepfungsziel } from '@/lib/validierung/dokumente'
import {
dokumentVerknuepfen,
verknuepfungLoesen,
type Aktionszustand,
} from '@/server/actions/dokumente'
export type Verknuepfungseintrag = {
id: string
ziel: Verknuepfungsziel
zielId: string
bezeichnung: string
bemerkung: string | null
/** Wohin fuehrt der Link in der Anwendung? */
href: string | null
}
export type Zielauswahl = {
ziel: Verknuepfungsziel
eintraege: { id: string; titel: string }[]
}
/**
* Verknuepfungen eines Dokuments mit der uebrigen Akte.
*
* Ein Bescheid gehoert zum Vorgang "Hilfe zur Pflege", begruendet eine
* Widerspruchsfrist und belegt eine Buchung. Erst diese Verbindungen
* machen aus einem Stapel Dateien eine Akte, in der man einen Sachverhalt
* von jeder Seite aus wiederfindet.
*/
export function Verknuepfungen({
dokumentId,
vorhandene,
auswahl,
schreibrecht,
}: {
dokumentId: string
vorhandene: Verknuepfungseintrag[]
auswahl: Zielauswahl[]
schreibrecht: boolean
}) {
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentVerknuepfen, {})
const [gewaehlt, setGewaehlt] = React.useState<Verknuepfungsziel>(auswahl[0]?.ziel ?? 'vorgang')
const moeglich = auswahl.find((eintrag) => eintrag.ziel === gewaehlt)?.eintraege ?? []
return (
<div className="space-y-4">
{vorhandene.length === 0 ? (
<Leerzustand
icon={Link2}
titel="Noch nicht zugeordnet"
beschreibung="Ordne das Dokument einem Vorgang zu – dann taucht es dort auf, wo du es suchst."
/>
) : (
<ul className="divide-y rounded-lg border">
{vorhandene.map((eintrag) => (
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{ZIEL_BESCHRIFTUNG[eintrag.ziel]}
</span>
<span className="min-w-0 flex-1">
{eintrag.href ? (
<Link href={eintrag.href} className="font-medium hover:underline">
{eintrag.bezeichnung}
</Link>
) : (
<span className="font-medium">{eintrag.bezeichnung}</span>
)}
{eintrag.bemerkung && (
<span className="block text-sm text-muted-foreground">{eintrag.bemerkung}</span>
)}
</span>
{schreibrecht && <LoesenKnopf verknuepfungId={eintrag.id} />}
</li>
))}
</ul>
)}
{schreibrecht && (
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
<input type="hidden" name="dokumentId" value={dokumentId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="ziel" beschriftung="Womit verknüpfen?">
<Select
name="ziel"
value={gewaehlt}
onChange={(ereignis) => setGewaehlt(ereignis.target.value as Verknuepfungsziel)}
>
{auswahl.map((eintrag) => (
<option key={eintrag.ziel} value={eintrag.ziel}>
{ZIEL_BESCHRIFTUNG[eintrag.ziel]} ({eintrag.eintraege.length})
</option>
))}
</Select>
</Feld>
<Feld
id="zielId"
beschriftung={ZIEL_BESCHRIFTUNG[gewaehlt]}
erklaerung={moeglich.length === 0 ? 'Hier gibt es noch nichts zum Verknüpfen.' : undefined}
>
<Select name="zielId" disabled={moeglich.length === 0}>
{moeglich.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.titel}
</option>
))}
</Select>
</Feld>
<Feld id="bemerkung" beschriftung="Bemerkung (optional)" className="sm:col-span-2">
<Input name="bemerkung" maxLength={200} placeholder="z. B. Beleg zur Zahlung vom 12.03." />
</Feld>
</div>
<VerknuepfenKnopf gesperrt={moeglich.length === 0} />
</form>
)}
</div>
)
}
function VerknuepfenKnopf({ gesperrt }: { gesperrt: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || gesperrt}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Plus aria-hidden="true" />}
Verknüpfen
</Button>
)
}
function LoesenKnopf({ verknuepfungId }: { verknuepfungId: string }) {
const [laeuft, setLaeuft] = React.useState(false)
return (
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={async () => {
setLaeuft(true)
await verknuepfungLoesen(verknuepfungId)
setLaeuft(false)
}}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Link2Off aria-hidden="true" />
)}
Lösen
</Button>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import type { Formularzustand } from '@/server/actions/finanzen'
type Aktion = (zustand: Formularzustand, daten: FormData) => Promise<Formularzustand>
/**
* Löschknopf mit Rückfrage.
*
* Gelöscht wird im Papierkorb-Sinn: geloeschtAm wird gesetzt, der
* Datensatz bleibt 30 Tage bestehen. Bei Saldoständen und Wertständen
* ist es dagegen ein echtes Entfernen – deshalb steht bei denen ein
* deutlicherer Text.
*/
export function Loeschknopf({
aktion,
felder,
beschriftung = 'Löschen',
frage,
variante = 'ghost',
}: {
aktion: Aktion
felder: Record<string, string>
beschriftung?: string
frage: string
variante?: 'ghost' | 'destructive' | 'outline'
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(aktion, {})
const [gefragt, setGefragt] = React.useState(false)
if (!gefragt) {
return (
<div className="space-y-2">
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Button type="button" variant={variante} size="sm" onClick={() => setGefragt(true)}>
<Trash2 aria-hidden="true" />
{beschriftung}
</Button>
</div>
)
}
return (
<form action={absenden} className="space-y-2 rounded-md border border-destructive/50 p-3">
{Object.entries(felder).map(([name, wert]) => (
<input key={name} type="hidden" name={name} value={wert} />
))}
<p className="text-sm">{frage}</p>
<div className="flex gap-2">
<Absendeknopf variant="destructive" size="sm" wartetext="Wird gelöscht …">
Ja, löschen
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
+206
View File
@@ -0,0 +1,206 @@
import * as React from 'react'
import Link from 'next/link'
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils'
/**
* Kleine Bausteine, die in mehreren Finanzseiten vorkommen.
*
* Alle rein darstellend und ohne Zustand - sie laufen deshalb sowohl in
* Server- als auch in Client-Komponenten. Betraege kommen hier immer als
* Number in Cent an; die Umwandlung aus BigInt passiert vorher mit
* centZuZahl().
*/
/**
* Ein Geldbetrag. Ausgaben werden mit Minuszeichen UND in gedeckter
* Farbe gezeigt - die Farbe allein waere fuer farbfehlsichtige Nutzer
* keine Information.
*/
export function Betrag({
cent,
richtung,
fett,
className,
}: {
cent: number | null
richtung?: 'EINNAHME' | 'AUSGABE' | 'UMBUCHUNG'
fett?: boolean
className?: string
}) {
if (cent === null) {
return <span className={cn('tabular-nums text-muted-foreground', className)}>nicht erfasst</span>
}
const vorzeichenbehaftet = richtung === 'AUSGABE' ? -Math.abs(cent) : cent
const negativ = vorzeichenbehaftet < 0
return (
<span
className={cn(
'tabular-nums',
fett && 'font-semibold',
negativ ? 'text-ampel-rot' : undefined,
className,
)}
>
{formatCent(vorzeichenbehaftet)}
</span>
)
}
/** Kachel mit einer Zahl und einer Erklärung, was sie bedeutet. */
export function Kennzahl({
titel,
wert,
hinweis,
ton = 'neutral',
href,
}: {
titel: string
wert: React.ReactNode
hinweis?: React.ReactNode
ton?: 'neutral' | 'gut' | 'achtung' | 'schlecht'
href?: string
}) {
const rahmen =
ton === 'gut'
? 'border-ampel-gruen/40'
: ton === 'achtung'
? 'border-ampel-orange/50'
: ton === 'schlecht'
? 'border-ampel-rot/50'
: undefined
const inhalt = (
<CardContent className="space-y-1 p-4">
<p className="text-sm font-medium text-muted-foreground">{titel}</p>
<p className="text-2xl font-semibold tabular-nums">{wert}</p>
{hinweis && <p className="text-sm text-muted-foreground">{hinweis}</p>}
</CardContent>
)
if (href) {
return (
<Card className={cn('transition-colors hover:bg-accent/40', rahmen)}>
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{inhalt}
</Link>
</Card>
)
}
return <Card className={rahmen}>{inhalt}</Card>
}
/**
* Belegzustand einer Buchung. Der Belegzwang verbietet nichts - er macht
* sichtbar. Eine Buchung ohne Beleg und ohne Begruendung ist die, nach
* der beim Rechtspfleger gefragt wird.
*/
export function Belegzeichen({
belegTitel,
begruendung,
belegId,
}: {
belegId: string | null
belegTitel: string | null
begruendung: string | null
}) {
if (belegId) {
return (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Paperclip className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{belegTitel ?? 'Beleg hinterlegt'}</span>
</span>
)
}
if (begruendung && begruendung.trim() !== '') {
return (
<Badge variant="gelb" title={begruendung}>
<FileWarning className="size-3.5" aria-hidden="true" />
Beleg begründet fehlend
</Badge>
)
}
return (
<Badge variant="rot">
<AlertTriangle className="size-3.5" aria-hidden="true" />
Beleg fehlt
</Badge>
)
}
/**
* Die Warnung, um die es bei der Trennung der Vermoegensmassen geht:
* eine Buchung auf einem Konto des Betreuers, die trotzdem als Vermoegen
* der betreuten Person gefuehrt wird.
*/
export function Vermischungswarnung({ kurz = false }: { kurz?: boolean }) {
if (kurz) {
return (
<Badge variant="rot">
<AlertTriangle className="size-3.5" aria-hidden="true" />
Vermögen vermischt
</Badge>
)
}
return (
<div className="flex items-start gap-2.5 rounded-md border border-ampel-rot/50 bg-ampel-rot/10 p-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-ampel-rot" aria-hidden="true" />
<div className="space-y-1 text-sm">
<p className="font-medium">Vorsicht: hier vermischen sich zwei Vermögen.</p>
<p className="text-muted-foreground">
Die Buchung läuft über ein Konto, das als <strong>Konto des Betreuers</strong> markiert
ist, gilt aber als Vermögen der betreuten Person. Genau das fällt einem in der
Rechnungslegung um die Ohren. Entweder ist das Konto falsch markiert, oder es ist eine
eigene Auslage – dann bitte den Haken bei „betrifft das Vermögen der betreuten Person“
entfernen.
</p>
</div>
</div>
)
}
/** Zeile in einer Bestands- oder Summenaufstellung. */
export function Summenzeile({
beschriftung,
cent,
betont,
}: {
beschriftung: React.ReactNode
cent: number
betont?: boolean
}) {
return (
<div
className={cn(
'flex items-baseline justify-between gap-4 border-t py-2',
betont && 'border-foreground/40 font-semibold',
)}
>
<span>{beschriftung}</span>
<Betrag cent={cent} fett={betont} />
</div>
)
}
/** Hinweise aus einer Berechnung - Lücken werden benannt, nicht versteckt. */
export function Hinweisliste({ hinweise, titel }: { hinweise: string[]; titel?: string }) {
if (hinweise.length === 0) return null
return (
<div className="rounded-md border border-ampel-orange/50 bg-ampel-orange/10 p-3">
<p className="mb-1.5 text-sm font-medium">{titel ?? 'Das solltest du vor der Abgabe klären'}</p>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{hinweise.map((hinweis, i) => (
<li key={i}>{hinweis}</li>
))}
</ul>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Filter, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import {
ALLE_KATEGORIEN,
BUCHUNG_RICHTUNGEN,
KATEGORIE_BESCHRIFTUNG,
RICHTUNG_BESCHRIFTUNG,
} from '@/lib/finanzen/kategorien'
/**
* Filter der Buchungsliste.
*
* Der Zustand steht in der Adresszeile, nicht in einem React-State: So
* lässt sich eine gefilterte Ansicht als Lesezeichen ablegen („alle
* Heimkosten 2026“) und beim Zurückgehen bleibt sie erhalten.
*/
export function Buchungsfilter({
konten,
aktiv,
}: {
konten: { id: string; bezeichnung: string }[]
aktiv: {
kontoId: string
kategorie: string
richtung: string
von: string
bis: string
suche: string
nurOhneBeleg: boolean
nurEigeneAuslagen: boolean
}
}) {
const router = useRouter()
const parameter = useSearchParams()
const [offen, setOffen] = React.useState(
Boolean(
aktiv.kategorie ||
aktiv.richtung ||
aktiv.von ||
aktiv.bis ||
aktiv.nurOhneBeleg ||
aktiv.nurEigeneAuslagen,
),
)
function anwenden(formular: FormData) {
const neu = new URLSearchParams()
for (const [schluessel, wert] of formular.entries()) {
if (typeof wert === 'string' && wert !== '') neu.set(schluessel, wert)
}
router.push(`/finanzen/buchungen?${neu.toString()}`)
}
const anzahlAktiv = [
aktiv.kontoId,
aktiv.kategorie,
aktiv.richtung,
aktiv.von,
aktiv.bis,
aktiv.suche,
aktiv.nurOhneBeleg ? 'x' : '',
aktiv.nurEigeneAuslagen ? 'x' : '',
].filter(Boolean).length
return (
<form action={anwenden} className="space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[16rem] flex-1">
<Feld
id="suche"
beschriftung="Suche"
erklaerung="Sucht in Gegenstelle und Kategorie. Der Verwendungszweck ist verschlüsselt und lässt sich nicht durchsuchen."
>
<Input name="suche" defaultValue={aktiv.suche} placeholder="Seniowohnpark, Rente …" />
</Feld>
</div>
<div className="min-w-[14rem]">
<Feld id="kontoId" beschriftung="Konto">
<Select name="kontoId" defaultValue={aktiv.kontoId}>
<option value="">alle Konten</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
</div>
<Button type="submit" variant="secondary">
<Filter aria-hidden="true" />
Filtern
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setOffen((wert) => !wert)}
aria-expanded={offen}
>
{offen ? 'Weniger' : 'Mehr Filter'}
{anzahlAktiv > 0 && !offen ? ` (${anzahlAktiv} aktiv)` : ''}
</Button>
{parameter.size > 0 && (
<Button type="button" variant="ghost" onClick={() => router.push('/finanzen/buchungen')}>
<RotateCcw aria-hidden="true" />
Zurücksetzen
</Button>
)}
</div>
{offen && (
<div className="grid gap-4 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="richtung" beschriftung="Richtung">
<Select name="richtung" defaultValue={aktiv.richtung}>
<option value="">alle</option>
{BUCHUNG_RICHTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{RICHTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="kategorie" beschriftung="Kategorie">
<Select name="kategorie" defaultValue={aktiv.kategorie}>
<option value="">alle</option>
{ALLE_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="von" beschriftung="Buchungen ab">
<Input type="date" name="von" defaultValue={aktiv.von} />
</Feld>
<Feld id="bis" beschriftung="Buchungen bis">
<Input type="date" name="bis" defaultValue={aktiv.bis} />
</Feld>
<label className="flex items-center gap-3 sm:col-span-2">
<Checkbox name="nurOhneBeleg" defaultChecked={aktiv.nurOhneBeleg} />
<span className="text-sm">Nur Buchungen ohne Beleg und ohne Begründung</span>
</label>
<label className="flex items-center gap-3 sm:col-span-2">
<Checkbox name="nurEigeneAuslagen" defaultChecked={aktiv.nurEigeneAuslagen} />
<span className="text-sm">Nur eigene Auslagen des Betreuers</span>
</label>
</div>
)}
</form>
)
}
@@ -0,0 +1,343 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import { Vermischungswarnung } from '@/components/finanzen/bausteine'
import {
ALLE_KATEGORIEN,
BUCHUNG_RICHTUNGEN,
KATEGORIE_BESCHRIFTUNG,
RICHTUNG_BESCHRIFTUNG,
kategorienFuerRichtung,
standardKategorie,
} from '@/lib/finanzen/kategorien'
import { buchungSpeichern, type Formularzustand } from '@/server/actions/finanzen'
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
export type KontoAuswahl = {
id: string
bezeichnung: string
istBetreuerkonto: boolean
}
export type BelegAuswahl = {
id: string
titel: string
}
export type BuchungWerte = {
id: string
kontoId: string | null
datum: string
wertstellung: string | null
betrag: string
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
/**
* Buchung erfassen oder ändern.
*
* Drei Dinge macht dieses Formular anders als eine Haushaltsbuch-App:
*
* 1. Es warnt sichtbar, wenn eine Buchung auf einem Konto des Betreuers
* läuft und trotzdem als Vermögen der Betreuten gilt.
* 2. Es lässt eine Buchung ohne Beleg zu – verlangt dann aber eine
* Begründung, weil genau danach gefragt wird.
* 3. Es trennt laufende Vorgänge von vermögenswirksamen. Ein
* Verkaufserlös ist keine Einnahme im Sinne der Rechnungslegung.
*/
export function Buchungsformular({
betreuteId,
konten,
belege,
buchung,
standardDatum,
schnellerfassung = false,
}: {
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
/** Nach dem Speichern bleibt das Formular für die nächste Buchung offen. */
schnellerfassung?: boolean
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(buchungSpeichern, {})
const [kontoId, setKontoId] = React.useState(buchung?.kontoId ?? konten[0]?.id ?? '')
const [richtung, setRichtung] = React.useState<BuchungRichtung>(buchung?.richtung ?? 'AUSGABE')
const [kategorie, setKategorie] = React.useState<BuchungKategorie>(
buchung?.kategorie ?? 'SONSTIGE_AUSGABE',
)
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
const vorschlaege = kategorienFuerRichtung(richtung)
function richtungWechseln(neu: BuchungRichtung) {
setRichtung(neu)
// Die Kategorie folgt der Richtung, solange sie nicht dazu passt -
// sonst steht "Heimkosten" unter den Einnahmen.
if (!kategorienFuerRichtung(neu).includes(kategorie)) {
setKategorie(standardKategorie(neu))
}
}
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{buchung && <input type="hidden" name="buchungId" value={buchung.id} />}
{schnellerfassung && !buchung && <input type="hidden" name="weiterErfassen" value="on" />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="datum"
beschriftung="Buchungsdatum"
pflicht
erklaerung="Der Tag, an dem das Geld geflossen ist."
fehler={zustand.felder?.datum}
>
<Input
type="date"
name="datum"
defaultValue={buchung?.datum ?? standardDatum}
required
/>
</Feld>
<Feld
id="wertstellung"
beschriftung="Wertstellung"
erklaerung="Optional – nur, wenn sie vom Buchungsdatum abweicht."
fehler={zustand.felder?.wertstellung}
>
<Input type="date" name="wertstellung" defaultValue={buchung?.wertstellung ?? ''} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
pflicht
erklaerung="Immer positiv eintragen. Ob es Geld kostet oder bringt, sagt die Richtung."
fehler={zustand.felder?.betrag}
>
<Betragseingabe id="betrag" name="betrag" standardwert={buchung?.betrag} pflicht />
</Feld>
<Feld
id="richtung"
beschriftung="Richtung"
pflicht
erklaerung="Umbuchungen zwischen eigenen Konten zählen weder als Einnahme noch als Ausgabe."
fehler={zustand.felder?.richtung}
>
<Select
name="richtung"
value={richtung}
onChange={(ereignis) => richtungWechseln(ereignis.target.value as BuchungRichtung)}
>
{BUCHUNG_RICHTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{RICHTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="kategorie"
beschriftung="Kategorie"
pflicht
erklaerung="Nach diesen Kategorien ordnet die Rechnungslegung Einnahmen und Ausgaben."
fehler={zustand.felder?.kategorie}
>
<Select
name="kategorie"
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value as BuchungKategorie)}
>
<optgroup label={`Passend zu „${RICHTUNG_BESCHRIFTUNG[richtung]}“`}>
{vorschlaege.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
<optgroup label="Alle übrigen">
{ALLE_KATEGORIEN.filter((wert) => !vorschlaege.includes(wert)).map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
</Select>
</Feld>
<Feld
id="kontoId"
beschriftung="Konto"
erklaerung="Ohne Konto lässt sich die Buchung später keinem Kontostand zuordnen."
fehler={zustand.felder?.kontoId}
>
<Select
name="kontoId"
value={kontoId}
onChange={(ereignis) => setKontoId(ereignis.target.value)}
>
<option value="">– kein Konto –</option>
{konten.map((k) => (
<option key={k.id} value={k.id}>
{k.bezeichnung}
{k.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="gegenstelle"
beschriftung="Gegenstelle"
erklaerung="Wer hat gezahlt oder wer wurde bezahlt? Steht später im Belegverzeichnis."
fehler={zustand.felder?.gegenstelle}
>
<Input name="gegenstelle" defaultValue={buchung?.gegenstelle ?? ''} maxLength={160} />
</Feld>
<Feld
id="verwendungszweck"
beschriftung="Verwendungszweck"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.verwendungszweck}
>
<Textarea
name="verwendungszweck"
defaultValue={buchung?.verwendungszweck ?? ''}
rows={2}
maxLength={500}
/>
</Feld>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Beleg</legend>
<Feld
id="belegId"
beschriftung="Beleg aus den Dokumenten"
erklaerung="Rechnung, Quittung oder Kontoauszug, der diese Buchung belegt."
fehler={zustand.felder?.belegId}
>
<Select
name="belegId"
value={belegId}
onChange={(ereignis) => setBelegId(ereignis.target.value)}
>
<option value="">– kein Beleg hinterlegt –</option>
{belege.map((beleg) => (
<option key={beleg.id} value={beleg.id}>
{beleg.titel}
</option>
))}
</Select>
</Feld>
{belegId === '' && (
<Feld
id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?"
erklaerung="Ohne Beleg und ohne Begründung wird die Buchung in der Liste rot markiert und in der Belegquote gezählt. Verboten ist sie nicht – aber beim Gericht wird danach gefragt."
fehler={zustand.felder?.belegFehltBegruendung}
>
<Input
name="belegFehltBegruendung"
defaultValue={buchung?.belegFehltBegruendung ?? ''}
maxLength={300}
placeholder="z.B. Barkauf im Kiosk, keine Quittung erhalten"
/>
</Feld>
)}
</fieldset>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Zuordnung</legend>
<label className="flex items-start gap-3">
<Checkbox
name="betrifftBetreute"
checked={betrifftBetreute}
onChange={(ereignis) => setBetrifftBetreute(ereignis.target.checked)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">
Betrifft das Vermögen der betreuten Person
</span>
<span className="block text-sm text-muted-foreground">
Abwählen, wenn du die Ausgabe aus eigener Tasche bezahlt hast. Solche Auslagen
stehen in der Rechnungslegung getrennt und werden nach §§ 1877, 1878 BGB erstattet.
</span>
</span>
</label>
{vermischt && <Vermischungswarnung />}
<label className="flex items-start gap-3">
<Checkbox
name="vermoegenswirksam"
defaultChecked={buchung?.vermoegenswirksam}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">Vermögenswirksam</span>
<span className="block text-sm text-muted-foreground">
Für Vorgänge, die Vermögen verschieben statt laufend einzunehmen oder auszugeben –
Verkaufserlös, ausgezahlter Rückkaufswert, Erbschaft. Sie zählen in der
Rechenprobe mit, gelten aber nicht als laufende Einnahme.
</span>
</span>
</label>
</fieldset>
<div className="flex flex-wrap gap-3">
<Absendeknopf>
{buchung ? 'Änderungen speichern' : schnellerfassung ? 'Speichern und nächste' : 'Buchung erfassen'}
</Absendeknopf>
</div>
</form>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { Check, Loader2, X } from 'lucide-react'
import { Button, type ButtonProps } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ibanFormatieren, ibanPruefen } from '@/lib/finanzen/iban'
/**
* Eingabefelder, die im Finanzmodul mehrfach vorkommen.
*
* Beide sind bewusst nachsichtig: Sie korrigieren nichts hinter dem
* Ruecken des Nutzers, sondern zeigen sofort, ob die Eingabe verstanden
* wurde. Die eigentliche Pruefung passiert trotzdem noch einmal auf dem
* Server - das Formular im Browser ist nur Bequemlichkeit.
*/
/**
* Betragsfeld. inputMode="decimal" oeffnet am Handy die Zifferntastatur
* mit Komma; type="text" statt type="number", weil ein Zahlenfeld in
* deutschen Browsern beim Komma Aerger macht und Mausraeder Betraege
* verstellen koennen.
*/
export function Betragseingabe({
id,
name,
standardwert,
pflicht,
className,
...rest
}: {
id: string
name: string
standardwert?: string
pflicht?: boolean
className?: string
} & Omit<React.ComponentProps<'input'>, 'type' | 'defaultValue' | 'name' | 'id'>) {
return (
<div className="relative">
<Input
{...rest}
id={id}
name={name}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="1.234,56"
defaultValue={standardwert}
required={pflicht}
className={className}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
>
€
</span>
</div>
)
}
/**
* IBAN-Feld mit Vierergruppen und sofortiger Pruefziffernkontrolle.
* Die Rueckmeldung steht als Text daneben, nicht nur als Farbe.
*/
export function Ibaneingabe({
id,
name,
standardwert,
}: {
id: string
name: string
standardwert?: string | null
}) {
const [wert, setWert] = React.useState(ibanFormatieren(standardwert ?? ''))
const pruefung = React.useMemo(() => {
if (wert.trim() === '') return null
return ibanPruefen(wert)
}, [wert])
return (
<div className="space-y-1.5">
<Input
id={id}
name={name}
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="DE89 3704 0044 0532 0130 00"
value={wert}
onChange={(ereignis) => setWert(ibanFormatieren(ereignis.target.value))}
aria-invalid={pruefung && !pruefung.gueltig ? true : undefined}
/>
{pruefung && (
<p
className={
pruefung.gueltig
? 'flex items-center gap-1.5 text-sm text-ampel-gruen'
: 'flex items-start gap-1.5 text-sm text-destructive'
}
>
{pruefung.gueltig ? (
<>
<Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
Prüfziffer stimmt ({pruefung.land}).
</>
) : (
<>
<X className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{pruefung.meldung}
</>
)}
</p>
)}
</div>
)
}
/** Schaltfläche, die während des Absendens sperrt und das sagt. */
export function Absendeknopf({
children,
wartetext = 'Wird gespeichert …',
...rest
}: ButtonProps & { wartetext?: string }) {
const status = useFormStatus()
return (
<Button {...rest} type="submit" disabled={status.pending || rest.disabled}>
{status.pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
{status.pending ? wartetext : children}
</Button>
)
}
+136
View File
@@ -0,0 +1,136 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import { importAusfuehren, type Formularzustand } from '@/server/actions/finanzen'
/**
* Kontoumsätze aus einer Datei einlesen.
*
* Abtippen ist die Stelle, an der in der Rechnungslegung die Fehler
* entstehen. Deshalb dieser Weg – und deshalb der Dublettenschutz:
* Dieselbe Datei zweimal einzulesen legt nichts doppelt an.
*/
export function Importformular({
betreuteId,
konten,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string; istBetreuerkonto: boolean }[]
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(importAusfuehren, {})
return (
<form action={absenden} className="space-y-5">
<input type="hidden" name="betreuteId" value={betreuteId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>{zustand.erfolg}</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-disc space-y-1 pl-5">
{(zustand.meldung ?? []).map((zeile, i) => (
<li key={i}>{zeile}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{!zustand.erfolg && zustand.meldung && zustand.meldung.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Diese Zeilen waren nicht lesbar</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-disc space-y-1 pl-5">
{zustand.meldung.map((zeile, i) => (
<li key={i}>{zeile}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Feld
id="kontoId"
beschriftung="Konto"
pflicht
erklaerung="Zu welchem Konto gehören die Umsätze in der Datei?"
fehler={zustand.felder?.kontoId}
>
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
{konto.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
</option>
))}
</Select>
</Feld>
<Feld
id="datei"
beschriftung="Datei"
pflicht
erklaerung="CSV aus dem Online-Banking oder der elektronische Kontoauszug als CAMT.053-XML. Deutsche Zahlen (1.234,56), Semikolon oder Komma als Trennzeichen und Windows-Umlaute werden erkannt. Höchstens 8 MB."
fehler={zustand.felder?.datei}
>
<Input
type="file"
name="datei"
accept=".csv,.txt,.xml,text/csv,text/plain,application/xml,text/xml"
required
className="py-2"
/>
</Feld>
<label className="flex items-start gap-3">
<Checkbox name="betrifftBetreute" defaultChecked className="mt-0.5" />
<span className="space-y-1">
<span className="block font-medium">
Die Umsätze betreffen das Vermögen der betreuten Person
</span>
<span className="block text-sm text-muted-foreground">
Abwählen, wenn du ein eigenes Konto einliest, über das nur deine Auslagen laufen.
</span>
</span>
</label>
<label className="flex items-start gap-3">
<Checkbox name="dublettenUebernehmen" className="mt-0.5" />
<span className="space-y-1">
<span className="block font-medium">Auch bereits vorhandene Umsätze anlegen</span>
<span className="block text-sm text-muted-foreground">
Normalerweise nicht anhaken. Nur sinnvoll, wenn du weißt, dass es tatsächlich zwei
gleiche Buchungen gibt und der Dublettenschutz zu streng war.
</span>
</span>
</label>
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Kategorien werden nicht geraten</AlertTitle>
<AlertDescription>
Importierte Buchungen landen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“.
Eine automatisch geratene Kategorie wäre in der Rechnungslegung schlimmer als gar keine
– ordne sie danach in der Buchungsliste zu.
</AlertDescription>
</Alert>
<Absendeknopf wartetext="Datei wird gelesen …">Datei einlesen</Absendeknopf>
</form>
)
}
+183
View File
@@ -0,0 +1,183 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf, Ibaneingabe } from '@/components/finanzen/eingaben'
import {
KONTO_ARTEN,
KONTO_ART_BESCHRIFTUNG,
KONTO_ART_ERKLAERUNG,
KONTO_ARTEN_OHNE_IBAN,
} from '@/lib/finanzen/kategorien'
import { kontoSpeichern, type Formularzustand } from '@/server/actions/finanzen'
import type { KontoArt } from '@prisma/client'
/**
* Konto anlegen oder ändern.
*
* Die IBAN wird verschlüsselt gespeichert und zusätzlich als Blindindex
* abgelegt. Deshalb kann die Anwendung „gibt es dieses Konto schon?“
* beantworten, ohne die IBAN im Klartext in der Datenbank zu halten.
*/
export function Kontoformular({
betreuteId,
konto,
}: {
betreuteId: string
konto?: {
id: string
bezeichnung: string
bank: string | null
art: KontoArt
iban: string | null
bic: string | null
istBetreuerkonto: boolean
eroeffnetAm: string | null
geschlossenAm: string | null
}
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(kontoSpeichern, {})
const [art, setArt] = React.useState<KontoArt>(konto?.art ?? 'GIRO')
const [istBetreuerkonto, setIstBetreuerkonto] = React.useState(konto?.istBetreuerkonto ?? false)
const ibanNoetig = !KONTO_ARTEN_OHNE_IBAN.includes(art)
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{konto && <input type="hidden" name="kontoId" value={konto.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
pflicht
erklaerung="Wie du das Konto im Alltag nennst, z.B. „Girokonto Sparkasse“."
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={konto?.bezeichnung} required maxLength={120} />
</Feld>
<Feld
id="bank"
beschriftung="Bank oder Einrichtung"
erklaerung="Bei einem Verwahrgeldkonto der Name des Heims."
fehler={zustand.felder?.bank}
>
<Input name="bank" defaultValue={konto?.bank ?? ''} maxLength={120} />
</Feld>
<Feld
id="art"
beschriftung="Kontoart"
pflicht
erklaerung={KONTO_ART_ERKLAERUNG[art]}
fehler={zustand.felder?.art}
>
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as KontoArt)}
>
{KONTO_ARTEN.map((wert) => (
<option key={wert} value={wert}>
{KONTO_ART_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="bic"
beschriftung="BIC"
erklaerung="Optional. Acht oder elf Stellen."
fehler={zustand.felder?.bic}
>
<Input name="bic" defaultValue={konto?.bic ?? ''} maxLength={11} autoComplete="off" />
</Feld>
</div>
<Feld
id="iban"
beschriftung="IBAN"
erklaerung={
ibanNoetig
? 'Wird verschlüsselt gespeichert. Die Prüfziffer wird sofort nachgerechnet.'
: 'Für Barkasse und Verwahrgeld im Heim gibt es in der Regel keine IBAN – das Feld darf leer bleiben.'
}
fehler={zustand.felder?.iban}
>
<Ibaneingabe id="iban" name="iban" standardwert={konto?.iban ?? ''} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="eroeffnetAm"
beschriftung="Eröffnet am"
erklaerung="Optional. Hilft, alte Kontoauszüge einzuordnen."
fehler={zustand.felder?.eroeffnetAm}
>
<Input type="date" name="eroeffnetAm" defaultValue={konto?.eroeffnetAm ?? ''} />
</Feld>
<Feld
id="geschlossenAm"
beschriftung="Geschlossen am"
erklaerung="Ein geschlossenes Konto bleibt stehen – die Rechnungslegung braucht es weiter."
fehler={zustand.felder?.geschlossenAm}
>
<Input type="date" name="geschlossenAm" defaultValue={konto?.geschlossenAm ?? ''} />
</Feld>
</div>
<div className="space-y-2 rounded-md border p-4">
<label className="flex items-start gap-3">
<Checkbox
name="istBetreuerkonto"
defaultChecked={konto?.istBetreuerkonto}
onChange={(ereignis) => setIstBetreuerkonto(ereignis.target.checked)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">Das ist ein Konto des Betreuers</span>
<span className="block text-sm text-muted-foreground">
Also dein eigenes Konto, nicht das der betreuten Person. Buchungen darauf gehören
nicht in das Vermögen der Betreuten und tauchen in der Rechnungslegung nur als
eigene Auslage auf.
</span>
</span>
</label>
{istBetreuerkonto && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Zwei Vermögen, zwei Konten</AlertTitle>
<AlertDescription>
Von diesem Konto darf kein Geld der betreuten Person verwaltet werden. Wenn du hier
Buchungen erfasst, die das Vermögen der Betreuten betreffen, warnt dich die
Anwendung – das ist der Fehler, der in der Rechnungslegung Ärger macht.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex gap-3">
<Absendeknopf>{konto ? 'Änderungen speichern' : 'Konto anlegen'}</Absendeknopf>
</div>
</form>
)
}
@@ -0,0 +1,283 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import {
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
VERMOEGENS_ARTEN,
VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien'
import {
vermoegenspostenSpeichern,
wertstandAnlegen,
type Formularzustand,
} from '@/server/actions/finanzen'
import type { SozialhilfeBewertung, VermoegensArt } from '@prisma/client'
const BEWERTUNGEN: SozialhilfeBewertung[] = [
'UNGEPRUEFT',
'VERWERTBAR',
'GESCHUETZT',
'STRITTIG',
]
/**
* Vermögensposten anlegen oder ändern.
*
* Der Posten selbst trägt keinen Betrag – der steckt in den
* Wertständen. Ein Posten ist die Sache („Hausgrundstück
* Bayreuther Str. 4“), ein Wertstand ist eine Aussage über ihren Wert an
* einem bestimmten Tag mit einer bestimmten Quelle.
*/
export function Postenformular({
betreuteId,
konten,
posten,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string }[]
posten?: {
id: string
bezeichnung: string
art: VermoegensArt
kontoId: string | null
bewertungSozialhilfe: SozialhilfeBewertung
bewertungBegruendung: string | null
notizen: string | null
}
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
vermoegenspostenSpeichern,
{},
)
const [art, setArt] = React.useState<VermoegensArt>(posten?.art ?? 'VERSICHERUNG')
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{posten && <input type="hidden" name="postenId" value={posten.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
pflicht
erklaerung="So genau, dass der Posten im Verzeichnis eindeutig ist."
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={posten?.bezeichnung} required maxLength={160} />
</Feld>
<Feld
id="art"
beschriftung="Art"
pflicht
erklaerung={
art === 'VERBINDLICHKEIT'
? 'Verbindlichkeiten werden mit positivem Betrag geführt – dass sie das Vermögen mindern, ergibt sich aus der Art.'
: 'Bestimmt, unter welcher Überschrift der Posten im Vermögensverzeichnis steht.'
}
fehler={zustand.felder?.art}
>
<Select
name="art"
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as VermoegensArt)}
>
{VERMOEGENS_ARTEN.map((wert) => (
<option key={wert} value={wert}>
{VERMOEGENS_ART_EINZAHL[wert]}
</option>
))}
</Select>
</Feld>
</div>
{art === 'KONTO' && (
<>
<Feld
id="kontoId"
beschriftung="Zugehöriges Konto"
erklaerung="Verknüpft den Posten mit einem erfassten Konto."
fehler={zustand.felder?.kontoId}
>
<Select name="kontoId" defaultValue={posten?.kontoId ?? ''}>
<option value="">– keines –</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Konten stehen schon im Verzeichnis</AlertTitle>
<AlertDescription>
Konten kommen mit ihrem Saldostand ins Vermögensverzeichnis. Ein zusätzlicher Posten
der Art „Konto“ wird dort deshalb <strong>nicht</strong> mitgezählt – sonst stünde
dasselbe Guthaben zweimal in der Summe. Für Depots, deren Wert du gesondert
belegen willst, ist der Posten trotzdem sinnvoll.
</AlertDescription>
</Alert>
</>
)}
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung im Sozialhilfeverfahren"
erklaerung="Deine eigene Einschätzung und Dokumentation gegenüber dem Bezirk – keine Rechtsauskunft."
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
defaultValue={posten?.bewertungSozialhilfe ?? 'UNGEPRUEFT'}
>
{BEWERTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung der Einordnung"
erklaerung="Warum hältst du den Posten für geschützt oder verwertbar? Verweis auf § 90 SGB XII, Schonvermögen, Bestattungsvorsorge, selbst bewohntes Hausgrundstück. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.bewertungBegruendung}
>
<Textarea
name="bewertungBegruendung"
defaultValue={posten?.bewertungBegruendung ?? ''}
rows={3}
maxLength={1000}
/>
</Feld>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={posten?.notizen ?? ''} rows={3} maxLength={1000} />
</Feld>
<Absendeknopf>{posten ? 'Änderungen speichern' : 'Posten anlegen'}</Absendeknopf>
</form>
)
}
/**
* Wertstand erfassen.
*
* Die Wertquelle ist Pflichtfeld – und zwar nicht aus Formalismus: Eine
* Zahl im Vermögensverzeichnis, bei der sich nach zwei Jahren nicht mehr
* klären lässt, woher sie stammt, ist wertlos. Steht dagegen
* „Rückkaufswertmitteilung vom 12.03.2026“ daneben, ist die Rückfrage
* beantwortet, bevor sie gestellt wird.
*/
export function Wertstandformular({
betreuteId,
vermoegenspostenId,
dokumente,
standardStichtag,
}: {
betreuteId: string
vermoegenspostenId: string
dokumente: { id: string; titel: string }[]
standardStichtag?: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(wertstandAnlegen, {})
return (
<form action={absenden} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vermoegenspostenId" value={vermoegenspostenId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="stichtag"
beschriftung="Stichtag"
pflicht
erklaerung="Auf welchen Tag bezieht sich der Wert?"
fehler={zustand.felder?.stichtag}
>
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
</Feld>
<Feld id="wert" beschriftung="Wert" pflicht fehler={zustand.felder?.wert}>
<Betragseingabe id="wert" name="wert" pflicht />
</Feld>
</div>
<Feld
id="wertquelle"
beschriftung="Wertquelle"
pflicht
erklaerung="Woher stammt der Wert? „Rückkaufswertmitteilung vom 12.03.2026“, „Maklergutachten“, „Bodenrichtwert“, „eigene Schätzung“."
fehler={zustand.felder?.wertquelle}
>
<Input
name="wertquelle"
required
maxLength={200}
placeholder="Rückkaufswertmitteilung vom 12.03.2026"
/>
</Feld>
<Feld
id="nachweisDokumentId"
beschriftung="Nachweisdokument"
erklaerung="Optional, aber hilfreich: das Schreiben, aus dem der Wert stammt."
fehler={zustand.felder?.nachweisDokumentId}
>
<Select name="nachweisDokumentId" defaultValue="">
<option value="">– kein Nachweis hinterlegt –</option>
{dokumente.map((dokument) => (
<option key={dokument.id} value={dokument.id}>
{dokument.titel}
</option>
))}
</Select>
</Feld>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Input name="bemerkung" maxLength={500} />
</Feld>
<Absendeknopf>Wert festhalten</Absendeknopf>
</form>
)
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import { saldostandAnlegen, type Formularzustand } from '@/server/actions/finanzen'
/**
* Kontostand zu einem Stichtag festhalten.
*
* Ein Saldostand wird nie überschrieben. Jeder Eintrag bleibt stehen,
* damit sich eine einmal abgegebene Vermögensübersicht Jahre später
* exakt wieder herstellen lässt – auch dann, wenn inzwischen ein
* Kontoauszug korrigiert wurde.
*/
export function Saldoformular({
betreuteId,
konten,
kontoId,
standardStichtag,
}: {
betreuteId: string
konten: { id: string; bezeichnung: string }[]
kontoId?: string
standardStichtag?: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(saldostandAnlegen, {})
return (
<form action={absenden} className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
{kontoId ? (
<input type="hidden" name="kontoId" value={kontoId} />
) : (
<Feld id="kontoId" beschriftung="Konto" pflicht fehler={zustand.felder?.kontoId}>
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.bezeichnung}
</option>
))}
</Select>
</Feld>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="stichtag"
beschriftung="Stichtag"
pflicht
erklaerung="Der Stand gilt als Bestand am Ende dieses Tages."
fehler={zustand.felder?.stichtag}
>
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
</Feld>
<Feld
id="betrag"
beschriftung="Kontostand"
pflicht
erklaerung="Ein überzogenes Konto wird mit Minus eingetragen."
fehler={zustand.felder?.betrag}
>
<Betragseingabe id="betrag" name="betrag" pflicht />
</Feld>
</div>
<Feld
id="quelle"
beschriftung="Woher stammt der Stand?"
erklaerung="z.B. „Kontoauszug Nr. 3“ oder „Online-Banking, abgerufen am 15.03.“. Das beantwortet die Rückfrage des Rechtspflegers vorab."
fehler={zustand.felder?.quelle}
>
<Input name="quelle" maxLength={160} placeholder="Kontoauszug Nr. 3" />
</Feld>
<Absendeknopf>Kontostand festhalten</Absendeknopf>
</form>
)
}
+175
View File
@@ -0,0 +1,175 @@
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatCent, formatDatum } from '@/lib/utils'
import { Betrag } from '@/components/finanzen/bausteine'
export type Verlaufszeile = {
id: string
/** ISO-Datum, damit der Wert auch an Client-Komponenten weitergereicht werden kann. */
stichtag: string
cent: number
wertquelle: string
nachweisTitel: string | null
bemerkung: string | null
veraenderungCent: number | null
/** Aktion am Zeilenende, z.B. ein Löschknopf. */
aktion?: React.ReactNode
}
/**
* Wertverlauf eines Vermögenspostens: Tabelle plus eine kleine Kurve.
*
* Die Kurve ist bewusst handgezeichnetes SVG und kein Diagrammpaket.
* Sie soll eine einzige Frage beantworten – „geht es rauf oder runter?“ –
* und dabei nichts nachladen, sauber ausdrucken und in beiden
* Farbschemata lesbar bleiben. Die genauen Zahlen stehen darunter in
* der Tabelle; die Kurve ersetzt sie nicht.
*/
export function Wertverlauf({ zeilen }: { zeilen: Verlaufszeile[] }) {
return (
<div className="space-y-4">
<Verlaufskurve zeilen={zeilen} />
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead>Wert</TableHead>
<TableHead>Veränderung</TableHead>
<TableHead>Wertquelle</TableHead>
<TableHead>Nachweis</TableHead>
{zeilen.some((z) => z.aktion) && <TableHead className="text-right">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{[...zeilen]
.sort((a, b) => b.stichtag.localeCompare(a.stichtag))
.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell className="whitespace-nowrap">
{formatDatum(new Date(zeile.stichtag))}
</TableCell>
<TableCell>
<Betrag cent={zeile.cent} fett />
</TableCell>
<TableCell>
{zeile.veraenderungCent === null ? (
<span className="text-muted-foreground">erster Wert</span>
) : (
<span
className={
zeile.veraenderungCent < 0
? 'tabular-nums text-ampel-rot'
: zeile.veraenderungCent > 0
? 'tabular-nums text-ampel-gruen'
: 'tabular-nums text-muted-foreground'
}
>
{zeile.veraenderungCent > 0 ? '+' : ''}
{formatCent(zeile.veraenderungCent)}
</span>
)}
</TableCell>
<TableCell>
<span className="block">{zeile.wertquelle}</span>
{zeile.bemerkung && (
<span className="block text-sm text-muted-foreground">{zeile.bemerkung}</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{zeile.nachweisTitel ?? 'kein Dokument hinterlegt'}
</TableCell>
{zeilen.some((z) => z.aktion) && (
<TableCell className="text-right">{zeile.aktion}</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
const BREITE = 640
const HOEHE = 120
const RAND = 8
/** Normiert die Werte auf das Zeichenfeld und zeichnet eine Polylinie. */
export function Verlaufskurve({ zeilen }: { zeilen: Verlaufszeile[] }) {
if (zeilen.length < 2) {
return (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
Sobald ein zweiter Wertstand erfasst ist, siehst du hier den Verlauf.
</p>
)
}
const sortiert = [...zeilen].sort((a, b) => a.stichtag.localeCompare(b.stichtag))
const zeiten = sortiert.map((z) => new Date(z.stichtag).getTime())
const werte = sortiert.map((z) => z.cent)
const tMin = Math.min(...zeiten)
const tMax = Math.max(...zeiten)
const wMin = Math.min(...werte)
const wMax = Math.max(...werte)
const tSpanne = tMax - tMin || 1
const wSpanne = wMax - wMin || 1
const punkte = sortiert.map((zeile, i) => ({
x: RAND + ((zeiten[i]! - tMin) / tSpanne) * (BREITE - 2 * RAND),
y: HOEHE - RAND - ((werte[i]! - wMin) / wSpanne) * (HOEHE - 2 * RAND),
zeile,
}))
const linie = punkte.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
const flaeche = `${RAND},${HOEHE - RAND} ${linie} ${(BREITE - RAND).toFixed(1)},${HOEHE - RAND}`
const beschreibung =
`Wertverlauf von ${formatCent(werte[0]!)} am ${formatDatum(new Date(sortiert[0]!.stichtag))} ` +
`bis ${formatCent(werte[werte.length - 1]!)} am ` +
`${formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))}.`
return (
<figure className="space-y-1">
<div className="overflow-x-auto rounded-md border bg-card p-2">
<svg
viewBox={`0 0 ${BREITE} ${HOEHE}`}
className="h-28 w-full min-w-[20rem]"
role="img"
aria-label={beschreibung}
preserveAspectRatio="none"
>
<polygon points={flaeche} className="fill-primary/10" />
<polyline
points={linie}
fill="none"
className="stroke-primary"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{punkte.map((punkt) => (
<circle
key={punkt.zeile.id}
cx={punkt.x}
cy={punkt.y}
r={3}
className="fill-primary"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<figcaption className="flex flex-wrap justify-between gap-2 text-sm text-muted-foreground">
<span>
{formatDatum(new Date(sortiert[0]!.stichtag))} · {formatCent(wMin)} (niedrigster Wert)
</span>
<span>
{formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))} · {formatCent(wMax)}{' '}
(höchster Wert)
</span>
</figcaption>
</figure>
)
}
+57
View File
@@ -0,0 +1,57 @@
import Link from 'next/link'
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
import { cn } from '@/lib/utils'
import { hinweiseFuerKontext } from '@/lib/hinweise/laden'
/**
* Zeigt die Hinweiskarten, die zum Kontext einer Seite passen.
*
* Die Karten kommen aus daten/hinweise.json und sind kein Beiwerk: Sie
* stehen an der Stelle, an der ein Fehler teuer wuerde - die Warnung vor
* der Genehmigungspflicht steht neben der Wohnungsaufloesung, nicht in
* einem Handbuch. Zu jeder Karte gehoert der stehende Rechtshinweis: Das
* hier ist eine Gedaechtnisstuetze, keine Rechtsberatung.
*
* Kontexte sind Modulnamen ("IMMOBILIE"), "VORGANG:<Kategorie>",
* "VORLAGE:<schluessel>" oder "FRIST:<systemSchluessel>". Ein Ausloeser
* ohne Jahreszahl passt auch auf die Frist mit Jahreszahl, damit
* "FRIST:jahresbericht" nicht jaehrlich nachgepflegt werden muss.
*
* Serverkomponente - sie liest die Wissensbasis von der Platte.
*/
export function HinweisKarten({
kontext,
grenze = 3,
ueberschrift = 'Darauf solltest du achten',
className,
}: {
kontext: readonly string[]
/** Wie viele Karten hoechstens? Mehr als drei liest niemand. */
grenze?: number
ueberschrift?: string
className?: string
}) {
const karten = hinweiseFuerKontext(kontext, grenze)
if (karten.length === 0) return null
return (
<section className={cn('space-y-3', className)} aria-label={ueberschrift}>
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-base font-semibold">{ueberschrift}</h2>
<Link
href="/hinweise"
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Alle Hinweise
</Link>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{karten.map((karte) => (
<HinweisKarte key={karte.schluessel} karte={karte} klein />
))}
</div>
</section>
)
}
export { HinweisKarte }
+44
View File
@@ -0,0 +1,44 @@
import Link from 'next/link'
import { ExternalLink, Lightbulb } from 'lucide-react'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { Hinweiskarte } from '@/lib/hinweise/laden'
/**
* Die Darstellung einer einzelnen Hinweiskarte.
*
* Bewusst in einer eigenen Datei und ohne Zugriff auf die Wissensbasis:
* Diese Komponente wird auch aus der Suche im Browser heraus benutzt, und
* das Laden der JSON-Datei geht ueber "node:fs" - das darf nie im
* Browserbuendel landen. Deshalb hier nur der Typ, nie der Lader.
*/
export function HinweisKarte({ karte, klein = false }: { karte: Hinweiskarte; klein?: boolean }) {
return (
<Card className={cn(klein && 'shadow-none')}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Lightbulb className="mt-0.5 size-5 shrink-0 text-ampel-gelb" aria-hidden="true" />
<div className="min-w-0 space-y-1">
<CardTitle className={cn('leading-snug', klein ? 'text-base' : 'text-lg')}>
{karte.titel}
</CardTitle>
<Badge variant="outline">{karte.rechtsgrundlage}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-relaxed">{karte.text}</p>
<Link
href={karte.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{karte.rechtsgrundlage} im Gesetzestext nachlesen
<ExternalLink className="size-4" aria-hidden="true" />
</Link>
<Rechtshinweis rechtsgrundlage={karte.rechtsgrundlage} kurz />
</CardContent>
</Card>
)
}
+82
View File
@@ -0,0 +1,82 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Leerzustand } from '@/components/seiten-kopf'
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
import type { Hinweiskarte } from '@/lib/hinweise/laden'
/**
* Durchsuchbare Liste aller Hinweiskarten.
*
* Die Karten kommen fertig geladen von der Serverseite - gesucht wird im
* Browser, weil es nur ein paar Dutzend sind und jede Runde zum Server
* sich beim Tippen wie Warten anfuehlen wuerde. Die Suchlogik steht hier
* noch einmal, statt aus lib/hinweise/laden.ts importiert zu werden: Jenes
* Modul liest die Datei mit "node:fs" und gehoert nicht ins Browserbuendel.
*/
function suchen(karten: Hinweiskarte[], suche: string): Hinweiskarte[] {
const sortiert = [...karten].sort(
(a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'),
)
const begriff = suche.trim().toLowerCase()
if (begriff === '') return sortiert
return sortiert.filter((karte) =>
[karte.titel, karte.text, karte.rechtsgrundlage, karte.schluessel, ...karte.kontext]
.join(' ')
.toLowerCase()
.includes(begriff),
)
}
export function HinweisSuche({ karten }: { karten: Hinweiskarte[] }) {
const [suche, setSuche] = React.useState('')
const treffer = React.useMemo(() => suchen(karten, suche), [karten, suche])
return (
<div className="space-y-5">
<Feld
id="hinweis-suche"
beschriftung="Suchen"
erklaerung="Sucht in Überschrift, Text, Rechtsgrundlage und Auslöser – zum Beispiel „Genehmigung“, „1878“ oder „Heim“."
>
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 opacity-60"
aria-hidden="true"
/>
<Input
type="search"
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Stichwort oder Paragraph"
className="pl-9"
autoComplete="off"
/>
</div>
</Feld>
<p className="text-sm text-muted-foreground" aria-live="polite">
{treffer.length === karten.length
? `${karten.length} Hinweise`
: `${treffer.length} von ${karten.length} Hinweisen`}
</p>
{treffer.length === 0 ? (
<Leerzustand
titel="Dazu ist nichts hinterlegt"
beschreibung="Die Hinweise stehen in daten/hinweise.json. Fehlt dir etwas, das du immer wieder nachschlägst, trag es dort nach – die Datei wird beim nächsten Start eingelesen."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
{treffer.map((karte) => (
<HinweisKarte key={karte.schluessel} karte={karte} />
))}
</div>
)}
</div>
)
}
+254
View File
@@ -0,0 +1,254 @@
'use client'
import { useActionState, useState } from 'react'
import { BookOpen, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BELASTUNG_ARTEN,
BELASTUNG_ART_ABTEILUNG,
GRUNDBUCH_ABTEILUNGEN,
GRUNDBUCH_ABTEILUNG_ERKLAERUNG,
} from '@/lib/immobilie/konstanten'
import { belastungLoeschen, belastungSpeichern } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type BelastungZeile = {
id: string
art: string
abteilung: string | null
laufendeNr: string | null
glaeubiger: string | null
betragCent: number | null
bemerkung: string | null
geloeschtImGrundbuchAm: string | null
}
export function Belastungen({
betreuteId,
immobilieId,
belastungen,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
belastungen: BelastungZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(belastungSpeichern, {})
const [formularOffen, setFormularOffen] = useState(false)
const [art, setArt] = useState<string>('Grundschuld')
const vorschlag = BELASTUNG_ART_ABTEILUNG[art as keyof typeof BELASTUNG_ART_ABTEILUNG] ?? 'II'
const aktive = belastungen.filter((b) => !b.geloeschtImGrundbuchAm)
const summe = aktive
.filter((b) => b.abteilung === 'III')
.reduce((wert, b) => wert + (b.betragCent ?? 0), 0)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="size-5" aria-hidden="true" />
Belastungen im Grundbuch
</CardTitle>
<CardDescription>
Was in Abteilung II und III eingetragen ist. Beides mindert den Wert — auf ganz
unterschiedliche Weise.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
{GRUNDBUCH_ABTEILUNGEN.map((abteilung) => (
<div key={abteilung} className="rounded-md border bg-muted/40 p-3">
<p className="text-sm text-muted-foreground">
{GRUNDBUCH_ABTEILUNG_ERKLAERUNG[abteilung]}
</p>
</div>
))}
</div>
{belastungen.length === 0 ? (
<Leerzustand
titel="Noch keine Belastungen erfasst"
beschreibung="Nimm den Grundbuchauszug zur Hand und trage ein, was in Abteilung II und III steht. Ohne diese Zahlen lässt sich der Wert des Grundstücks nicht beziffern."
icon={BookOpen}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setFormularOffen(true)}>
<Plus aria-hidden="true" />
Belastung eintragen
</Button>
)
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Abt.</TableHead>
<TableHead>lfd. Nr.</TableHead>
<TableHead>Art</TableHead>
<TableHead>Gläubiger / Berechtigter</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Status</TableHead>
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{belastungen.map((belastung) => (
<TableRow key={belastung.id}>
<TableCell className="font-medium">{belastung.abteilung ?? '–'}</TableCell>
<TableCell>{belastung.laufendeNr ?? '–'}</TableCell>
<TableCell>{belastung.art}</TableCell>
<TableCell>{belastung.glaeubiger ?? '–'}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(belastung.betragCent)}
</TableCell>
<TableCell>
{belastung.geloeschtImGrundbuchAm ? (
<Badge variant="gruen">
gelöscht am {formatDatum(belastung.geloeschtImGrundbuchAm)}
</Badge>
) : (
<Badge variant="outline">eingetragen</Badge>
)}
</TableCell>
{darfSchreiben && (
<TableCell>
<form
action={async () => {
await belastungLoeschen(betreuteId, immobilieId, belastung.id)
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
aria-label={`${belastung.art} entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{summe > 0 && (
<p className="text-sm text-muted-foreground">
Nennbetrag der eingetragenen Grundpfandrechte (Abteilung III):{' '}
<strong className="text-foreground">{formatCent(summe)}</strong>. Das ist die
Höchstsumme aus dem Grundbuch, nicht die Restschuld — frag bei der Bank nach
einer aktuellen Ablösesumme.
</p>
)}
</>
)}
{darfSchreiben && (formularOffen || belastungen.length > 0) && (
<div className="rounded-md border p-4">
{!formularOffen ? (
<Button type="button" variant="outline" onClick={() => setFormularOffen(true)}>
<Plus aria-hidden="true" />
Weitere Belastung eintragen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="belastung-art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" value={art} onChange={(e) => setArt(e.target.value)}>
{BELASTUNG_ARTEN.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</Select>
</Feld>
<Feld
id="belastung-abteilung"
beschriftung="Grundbuchabteilung"
erklaerung={`Bei „${art}“ üblicherweise Abteilung ${vorschlag}.`}
fehler={zustand.felder?.abteilung}
>
<Select name="abteilung" defaultValue={vorschlag} key={vorschlag}>
{GRUNDBUCH_ABTEILUNGEN.map((a) => (
<option key={a} value={a}>
Abteilung {a}
</option>
))}
</Select>
</Feld>
<Feld
id="belastung-laufendeNr"
beschriftung="Laufende Nummer"
erklaerung="Die Nummer der Eintragung in der jeweiligen Abteilung."
fehler={zustand.felder?.laufendeNr}
>
<Input name="laufendeNr" maxLength={20} />
</Feld>
<Feld
id="belastung-glaeubiger"
beschriftung="Gläubiger / Berechtigter"
fehler={zustand.felder?.glaeubiger}
>
<Input name="glaeubiger" maxLength={150} />
</Feld>
<Feld
id="belastung-betrag"
beschriftung="Betrag"
erklaerung="Der eingetragene Nennbetrag, z. B. 50.000,00"
fehler={zustand.felder?.betrag}
>
<Input name="betrag" inputMode="decimal" placeholder="50.000,00" />
</Feld>
<Feld
id="belastung-geloescht"
beschriftung="Im Grundbuch gelöscht am"
fehler={zustand.felder?.geloeschtImGrundbuchAm}
>
<Input name="geloeschtImGrundbuchAm" type="date" />
</Feld>
</div>
<Feld id="belastung-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" rows={2} maxLength={1000} />
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Belastung speichern</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setFormularOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
)
}
+262
View File
@@ -0,0 +1,262 @@
'use client'
import { useActionState, useState } from 'react'
import { CalendarClock, ClipboardList, Plus, Repeat, Trash2, TriangleAlert } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
checklisteUebernehmen,
checkpunktErledigen,
checkpunktLoeschen,
checkpunktSpeichern,
} from '@/server/actions/immobilie'
import { formatDatum, relativeTage } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type CheckpunktZeile = {
id: string
titel: string
beschreibung: string | null
erledigt: boolean
erledigtAm: string | null
faelligAm: string | null
wiederholungRrule: string | null
tageBisFaellig: number | null
}
function ampel(punkt: CheckpunktZeile): { variante: 'rot' | 'orange' | 'gelb' | 'gruen'; text: string } {
if (punkt.erledigt) return { variante: 'gruen', text: 'erledigt' }
if (punkt.tageBisFaellig === null) return { variante: 'gelb', text: 'ohne Termin' }
if (punkt.tageBisFaellig < 0) {
return { variante: 'rot', text: `überfällig seit ${Math.abs(punkt.tageBisFaellig)} Tagen` }
}
if (punkt.tageBisFaellig <= 7) return { variante: 'orange', text: relativeTage(punkt.tageBisFaellig) }
if (punkt.tageBisFaellig <= 31) return { variante: 'gelb', text: relativeTage(punkt.tageBisFaellig) }
return { variante: 'gruen', text: relativeTage(punkt.tageBisFaellig) }
}
export function Checkliste({
betreuteId,
immobilieId,
punkte,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
punkte: CheckpunktZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checkpunktSpeichern, {})
const [uebernahme, uebernahmeAktion] = useActionState<Formularzustand, FormData>(
checklisteUebernehmen,
{},
)
const [offen, setOffen] = useState(false)
const erledigt = punkte.filter((p) => p.erledigt).length
const ueberfaellig = punkte.filter(
(p) => !p.erledigt && p.tageBisFaellig !== null && p.tageBisFaellig < 0,
)
if (punkte.length === 0) {
return (
<div className="space-y-4">
<Zustandsmeldung zustand={uebernahme} />
<Leerzustand
titel="Noch keine Checkliste angelegt"
beschreibung="Die vorbereitete Liste enthält zehn Punkte, die bei einem leerstehenden Haus regelmäßig teuer werden — vom Gebäudeversicherer bis zum Frostschutz. Jeder Punkt lässt sich danach ändern, verschieben oder löschen."
icon={ClipboardList}
aktion={
darfSchreiben && (
<form action={uebernahmeAktion}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Absendeknopf>Checkliste übernehmen</Absendeknopf>
</form>
)
}
/>
</div>
)
}
return (
<div className="space-y-5">
<Zustandsmeldung zustand={uebernahme} />
<Card>
<CardContent className="space-y-4 pt-5">
<Fortschritt
wert={erledigt}
gesamt={punkte.length}
beschriftung="Punkte abgehakt"
/>
{ueberfaellig.length > 0 && (
<p className="flex items-start gap-2 text-sm font-medium text-ampel-rot">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{ueberfaellig.length === 1
? '1 Punkt ist überfällig.'
: `${ueberfaellig.length} Punkte sind überfällig.`}{' '}
Bei Leerstand ist das kein Schönheitsfehler — der Versicherer fragt im
Schadensfall danach.
</p>
)}
</CardContent>
</Card>
<ul className="space-y-3">
{punkte.map((punkt) => {
const lage = ampel(punkt)
return (
<li key={punkt.id}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
{darfSchreiben && (
<form
action={async () => {
await checkpunktErledigen(
betreuteId,
immobilieId,
punkt.id,
!punkt.erledigt,
)
}}
>
<button
type="submit"
className="flex size-11 items-center justify-center rounded-md hover:bg-accent"
aria-label={
punkt.erledigt
? `„${punkt.titel}“ wieder öffnen`
: `„${punkt.titel}“ abhaken`
}
>
<Checkbox checked={punkt.erledigt} readOnly tabIndex={-1} />
</button>
</form>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<CardTitle className="text-base">{punkt.titel}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={lage.variante}>{lage.text}</Badge>
{punkt.faelligAm && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" aria-hidden="true" />
fällig {formatDatum(punkt.faelligAm)}
</span>
)}
{punkt.wiederholungRrule && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Repeat className="size-3.5" aria-hidden="true" />
wiederkehrend
</span>
)}
{punkt.erledigtAm && (
<span className="text-sm text-muted-foreground">
zuletzt erledigt {formatDatum(punkt.erledigtAm)}
</span>
)}
</div>
</div>
{darfSchreiben && (
<form
action={async () => {
await checkpunktLoeschen(betreuteId, immobilieId, punkt.id)
}}
>
<Button
type="submit"
variant="ghost"
size="icon"
aria-label={`„${punkt.titel}“ löschen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
)}
</div>
</CardHeader>
{punkt.beschreibung && (
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{punkt.beschreibung}</p>
</CardContent>
)}
</Card>
</li>
)
})}
</ul>
{darfSchreiben && (
<div className="rounded-md border p-4">
{!offen ? (
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Eigenen Punkt hinzufügen
</Button>
<form action={uebernahmeAktion}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Absendeknopf variant="ghost">Fehlende Standardpunkte nachholen</Absendeknopf>
</form>
</div>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<Feld id="punkt-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" required maxLength={200} />
</Feld>
<Feld
id="punkt-beschreibung"
beschriftung="Warum ist der Punkt wichtig?"
erklaerung="Schreib es auf. In sechs Monaten weißt du sonst nicht mehr, warum er auf der Liste steht."
fehler={zustand.felder?.beschreibung}
>
<Textarea name="beschreibung" rows={3} maxLength={2000} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="punkt-faellig" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" />
</Feld>
<Feld
id="punkt-rrule"
beschriftung="Wiederholung"
erklaerung="Leer lassen für einen einmaligen Punkt. Sonst z. B. FREQ=WEEKLY;INTERVAL=1 oder FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1"
fehler={zustand.felder?.wiederholungRrule}
>
<Input name="wiederholungRrule" maxLength={200} />
</Feld>
</div>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Punkt hinzufügen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useActionState, useState } from 'react'
import { Info } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { BEWOHNT_STATUS, BEWOHNT_STATUS_ERKLAERUNG } from '@/lib/immobilie/konstanten'
import { immobilieSpeichern } from '@/server/actions/immobilie'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type ImmobilieWerte = {
id?: string
bezeichnung: string
strasse: string
plz: string
ort: string
grundbuchamt: string
grundbuchBlatt: string
gemarkung: string
flurstueck: string
eigentumsanteil: string
wohnflaecheQm: string
grundstueckQm: string
baujahr: string
bewohntStatus: string
leerstandSeit: string
notizen: string
}
export const LEERE_IMMOBILIE: ImmobilieWerte = {
bezeichnung: '',
strasse: '',
plz: '',
ort: '',
grundbuchamt: '',
grundbuchBlatt: '',
gemarkung: '',
flurstueck: '',
eigentumsanteil: '1/1',
wohnflaecheQm: '',
grundstueckQm: '',
baujahr: '',
bewohntStatus: '',
leerstandSeit: '',
notizen: '',
}
export function ImmobilieFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: ImmobilieWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(immobilieSpeichern, {})
const [bewohnt, setBewohnt] = useState(werte.bewohntStatus)
const istNeu = !werte.id
const leerstehend = bewohnt === 'leerstehend'
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="immobilieId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe
titel="Objekt"
beschreibung="Wie das Grundstück im Alltag heißt und wo es liegt."
>
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du das Objekt wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
className="sm:col-span-2"
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input name="strasse" defaultValue={werte.strasse} maxLength={150} />
</Feld>
<div className="grid grid-cols-[7rem_1fr] gap-3">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input name="plz" defaultValue={werte.plz} inputMode="numeric" maxLength={10} />
</Feld>
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
<Input name="ort" defaultValue={werte.ort} maxLength={100} />
</Feld>
</div>
</Feldgruppe>
<Feldgruppe
titel="Grundbuch"
beschreibung="Die Angaben stehen auf dem Grundbuchauszug, erste Seite. Ohne sie lässt sich beim Grundbuchamt nichts abfragen."
>
<Feld
id="grundbuchamt"
beschriftung="Grundbuchamt"
erklaerung="Das Amtsgericht, bei dem das Grundbuch geführt wird."
fehler={zustand.felder?.grundbuchamt}
>
<Input name="grundbuchamt" defaultValue={werte.grundbuchamt} maxLength={150} />
</Feld>
<Feld
id="grundbuchBlatt"
beschriftung="Grundbuchblatt"
erklaerung="Die Blattnummer, unter der das Grundstück eingetragen ist."
fehler={zustand.felder?.grundbuchBlatt}
>
<Input name="grundbuchBlatt" defaultValue={werte.grundbuchBlatt} maxLength={50} />
</Feld>
<Feld
id="gemarkung"
beschriftung="Gemarkung"
erklaerung="Der Vermessungsbezirk — meist der Ortsname."
fehler={zustand.felder?.gemarkung}
>
<Input name="gemarkung" defaultValue={werte.gemarkung} maxLength={100} />
</Feld>
<Feld
id="flurstueck"
beschriftung="Flurstück"
erklaerung="Die Nummer des Flurstücks, z. B. „412/3“."
fehler={zustand.felder?.flurstueck}
>
<Input name="flurstueck" defaultValue={werte.flurstueck} maxLength={50} />
</Feld>
<Feld
id="eigentumsanteil"
beschriftung="Eigentumsanteil"
erklaerung="Wie viel gehört der betreuten Person? „1/1“ heißt Alleineigentum, „1/2“ hälftiger Miteigentumsanteil."
fehler={zustand.felder?.eigentumsanteil}
>
<Input name="eigentumsanteil" defaultValue={werte.eigentumsanteil} maxLength={20} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Gebäude" beschreibung="Zahlen für die Bewertung und für Anfragen bei Maklern.">
<Feld id="wohnflaecheQm" beschriftung="Wohnfläche in m²" fehler={zustand.felder?.wohnflaecheQm}>
<Input name="wohnflaecheQm" defaultValue={werte.wohnflaecheQm} inputMode="numeric" />
</Feld>
<Feld id="grundstueckQm" beschriftung="Grundstücksfläche in m²" fehler={zustand.felder?.grundstueckQm}>
<Input name="grundstueckQm" defaultValue={werte.grundstueckQm} inputMode="numeric" />
</Feld>
<Feld id="baujahr" beschriftung="Baujahr" fehler={zustand.felder?.baujahr}>
<Input name="baujahr" defaultValue={werte.baujahr} inputMode="numeric" />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Nutzung"
beschreibung="Ob jemand darin wohnt, entscheidet über die Pflichten und über die Behandlung in der Sozialhilfe."
>
<Feld
id="bewohntStatus"
beschriftung="Bewohnt-Status"
erklaerung={bewohnt ? BEWOHNT_STATUS_ERKLAERUNG[bewohnt as keyof typeof BEWOHNT_STATUS_ERKLAERUNG] : undefined}
fehler={zustand.felder?.bewohntStatus}
>
<Select
name="bewohntStatus"
value={bewohnt}
onChange={(e) => setBewohnt(e.target.value)}
>
<option value="">— bitte wählen —</option>
{BEWOHNT_STATUS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</Select>
</Feld>
<Feld
id="leerstandSeit"
beschriftung="Leer stehend seit"
erklaerung="Ab diesem Tag rechnen die Fälligkeiten der Leerstands-Checkliste."
fehler={zustand.felder?.leerstandSeit}
pflicht={leerstehend}
>
<Input name="leerstandSeit" type="date" defaultValue={werte.leerstandSeit} />
</Feld>
</Feldgruppe>
{leerstehend && istNeu && (
<Alert variant="warnung">
<Info aria-hidden="true" />
<AlertTitle>Ein leeres Haus ist kein ruhender Vermögensposten</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Bei Leerstand laufen Pflichten weiter: Der Gebäudeversicherer muss informiert
werden — sonst kann er im Schadensfall leistungsfrei werden. Dazu kommen
Kontrollgänge, Frostschutz, Winterdienst und die Verkehrssicherung.
</p>
<label className="flex items-start gap-2.5">
<Checkbox name="checklisteUebernehmen" defaultChecked className="mt-0.5" />
<span className="text-sm">
Pflichten-Checkliste gleich übernehmen (10 Punkte mit Fälligkeiten, jederzeit
änderbar)
</span>
</label>
</AlertDescription>
</Alert>
)}
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Absendeknopf>{istNeu ? 'Objekt anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}
+254
View File
@@ -0,0 +1,254 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { Camera, Package, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { VERBLEIB_MOEGLICHKEITEN } from '@/lib/immobilie/inventar'
import { inventarLoeschen, inventarSpeichern } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type InventarZeile = {
id: string
bezeichnung: string
raum: string | null
verbleib: string | null
bemerkung: string | null
wertCent: number | null
fotos: { id: string; titel: string; aufgenommenAm: string | null }[]
}
export function Inventar({
betreuteId,
immobilieId,
stuecke,
summeCent,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
stuecke: InventarZeile[]
summeCent: number
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(inventarSpeichern, {})
const [offen, setOffen] = useState(false)
return (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="size-5" aria-hidden="true" />
Fotodokumentation
</CardTitle>
<CardDescription>
Fotografiere jeden Raum vollständig, bevor etwas bewegt wird, dazu die
Wertgegenstände einzeln. Das schützt vor dem Vorwurf, es sei etwas verschwunden,
und belegt den Zustand des Hauses gegenüber Käufern und Versicherern.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href={`/dokumente/neu?art=FOTO&immobilieId=${immobilieId}`}>
<Camera aria-hidden="true" />
Fotos hochladen
</Link>
</Button>
<p className="mt-3 text-sm text-muted-foreground">
Die Fotos werden als Dokument mit der Art „Foto“ abgelegt und mit dem Objekt
verknüpft. Einzelne Gegenstände verknüpfst du zusätzlich mit dem passenden
Inventarposten.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="size-5" aria-hidden="true" />
Inventarliste der Wertgegenstände
</CardTitle>
<CardDescription>
Was ist da, wo steht es, was ist es ungefähr wert, und wo ist es geblieben? Die
Liste beantwortet später die Frage von Miterben und Gericht, was mit dem Hausrat
passiert ist.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{stuecke.length === 0 ? (
<Leerzustand
titel="Noch nichts erfasst"
beschreibung="Fang mit dem an, was auffällt: Schmuck, Uhren, Bilder, Werkzeug, ein Fahrzeug. Alltagsgegenstände einzeln aufzunehmen lohnt sich nicht — dafür genügen die Raumfotos."
icon={Package}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Gegenstand aufnehmen
</Button>
)
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Raum</TableHead>
<TableHead className="text-right">geschätzter Wert</TableHead>
<TableHead>Verbleib</TableHead>
<TableHead>Fotos</TableHead>
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{stuecke.map((stueck) => (
<TableRow key={stueck.id}>
<TableCell>
<span className="font-medium">{stueck.bezeichnung}</span>
{stueck.bemerkung && (
<span className="block text-sm text-muted-foreground">
{stueck.bemerkung}
</span>
)}
</TableCell>
<TableCell>{stueck.raum ?? '–'}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(stueck.wertCent)}
</TableCell>
<TableCell>
{stueck.verbleib ? (
<Badge variant="outline">{stueck.verbleib}</Badge>
) : (
'–'
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{stueck.fotos.length === 0
? 'keine'
: stueck.fotos
.map((f) => `${f.titel}${f.aufgenommenAm ? ` (${formatDatum(f.aufgenommenAm)})` : ''}`)
.join(', ')}
</TableCell>
{darfSchreiben && (
<TableCell>
<form
action={async () => {
await inventarLoeschen(betreuteId, immobilieId, stueck.id)
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
aria-label={`${stueck.bezeichnung} entfernen`}
>
<Trash2 aria-hidden="true" />
</Button>
</form>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
<p className="text-sm text-muted-foreground">
Summe der geschätzten Werte:{' '}
<strong className="text-foreground">{formatCent(summeCent)}</strong>. Liegt sie
niedrig, gehört das begründet in die Akte — dann lohnt sich eine Verwertung über
einen Händler nicht, und die Kosten einer Haushaltsauflösung sind der
sinnvollere Weg.
</p>
</>
)}
{darfSchreiben && (offen || stuecke.length > 0) && (
<div className="rounded-md border p-4">
{!offen ? (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Weiteren Gegenstand aufnehmen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="inventar-bezeichnung"
beschriftung="Bezeichnung"
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" required maxLength={150} />
</Feld>
<Feld id="inventar-raum" beschriftung="Raum" fehler={zustand.felder?.raum}>
<Input name="raum" maxLength={100} placeholder="Wohnzimmer" />
</Feld>
<Feld
id="inventar-wert"
beschriftung="Geschätzter Wert"
erklaerung="Eigene Schätzung. Wird mit dem heutigen Tag als Stichtag abgelegt."
fehler={zustand.felder?.geschaetzterWert}
>
<Input name="geschaetzterWert" inputMode="decimal" placeholder="250,00" />
</Feld>
<Feld
id="inventar-verbleib"
beschriftung="Verbleib"
fehler={zustand.felder?.verbleib}
>
<Select name="verbleib" defaultValue="im Haus">
{VERBLEIB_MOEGLICHKEITEN.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="inventar-bemerkung"
beschriftung="Bemerkung"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.bemerkung}
>
<Textarea name="bemerkung" rows={2} maxLength={1000} />
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Gegenstand aufnehmen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link'
import { Info, Receipt } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand } from '@/components/seiten-kopf'
import { IMMOBILIEN_KATEGORIE_BESCHRIFTUNG } from '@/lib/immobilie/konstanten'
import type { Kostenjahr } from '@/lib/immobilie/kostenkonto'
import { formatCent } from '@/lib/utils'
/**
* Kostenkonto des Objekts - reine Anzeige.
*
* Die Buchungen legt das Finanzmodul an. Hier steht nur die Auswertung:
* was kostet das Haus im Jahr, ohne dass jemand darin wohnt.
*/
export function Kostenkonto({
jahre,
durchschnittCent,
laufendesJahr,
mehrereObjekte,
}: {
jahre: (Omit<Kostenjahr, 'ausgabenCent' | 'einnahmenCent' | 'saldoCent' | 'ausgaben' | 'einnahmen'> & {
ausgabenCent: number
einnahmenCent: number
saldoCent: number
ausgaben: { kategorie: string; summeCent: number; anzahl: number }[]
einnahmen: { kategorie: string; summeCent: number; anzahl: number }[]
})[]
durchschnittCent: number
laufendesJahr: number
mehrereObjekte: boolean
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-5" aria-hidden="true" />
Kostenkonto
</CardTitle>
<CardDescription>
Was das Objekt kostet — zusammengestellt aus dem Kassenbuch, je Jahr summiert.
Grundsteuer, Betriebskosten (Gebäudeversicherung, Energie, Wasser,
Schornsteinfeger) und Instandhaltung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{jahre.length === 0 ? (
<Leerzustand
titel="Noch keine Buchungen zum Objekt"
beschreibung="Sobald im Kassenbuch Buchungen mit den Kategorien Grundsteuer, Immobilien-Betriebskosten oder Instandhaltung stehen, erscheinen sie hier automatisch."
icon={Receipt}
aktion={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen/neu">Buchung erfassen</Link>
</Button>
}
/>
) : (
<>
{jahre.map((jahr) => (
<div key={jahr.jahr} className="space-y-2">
<h3 className="text-base font-semibold">
{jahr.jahr}
{jahr.jahr === laufendesJahr && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
(läuft noch)
</span>
)}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategorie</TableHead>
<TableHead className="text-right">Buchungen</TableHead>
<TableHead className="text-right">Summe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jahr.ausgaben.map((zeile) => (
<TableRow key={zeile.kategorie}>
<TableCell>
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
] ?? zeile.kategorie}
</TableCell>
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCent(zeile.summeCent)}
</TableCell>
</TableRow>
))}
{jahr.einnahmen.map((zeile) => (
<TableRow key={zeile.kategorie}>
<TableCell>
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
] ?? zeile.kategorie}
</TableCell>
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
<TableCell className="text-right tabular-nums text-ampel-gruen">
+ {formatCent(zeile.summeCent)}
</TableCell>
</TableRow>
))}
<TableRow className="border-t-2">
<TableCell className="font-semibold">
{jahr.saldoCent < 0 ? 'Kosten im Jahr' : 'Überschuss im Jahr'}
</TableCell>
<TableCell className="text-right tabular-nums">
{jahr.anzahlBuchungen}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
{formatCent(Math.abs(jahr.saldoCent))}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
))}
{durchschnittCent > 0 && (
<p className="text-sm text-muted-foreground">
Durchschnittliche Kosten je abgeschlossenem Jahr:{' '}
<strong className="text-foreground">{formatCent(durchschnittCent)}</strong>. Diese
Zahl gehört in jedes Gespräch über Verkauf, Härtefall oder ein Darlehen nach
§ 91 SGB XII — ein leerstehendes Haus verzehrt Vermögen, auch wenn niemand
darin wohnt.
</p>
)}
</>
)}
<div className="flex items-start gap-2.5 rounded-md border bg-muted/40 p-3">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
Ausgewertet wird über die Buchungskategorien, weil eine Buchung im Datenmodell noch
keinem einzelnen Objekt zugeordnet werden kann.{' '}
{mehrereObjekte ? (
<strong className="text-foreground">
Es sind mehrere Objekte erfasst — die Zahlen enthalten die Kosten aller
Grundstücke zusammen.
</strong>
) : (
'Solange nur ein Objekt geführt wird, ist die Zuordnung eindeutig.'
)}{' '}
Buche die Gebäudeversicherung als Immobilien-Betriebskosten, sonst taucht sie hier
nicht auf.
</p>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,125 @@
'use client'
import { useActionState, useState } from 'react'
import type { ImmobilienStatusSozialhilfe } from '@prisma/client'
import { Landmark } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
IMMOBILIEN_STATUS_BESCHRIFTUNG,
IMMOBILIEN_STATUS_ERKLAERUNG,
IMMOBILIEN_STATUS_RECHTSGRUNDLAGE,
} from '@/lib/immobilie/konstanten'
import { statusSozialhilfeSpeichern } from '@/server/actions/immobilie'
import type { Formularzustand } from '@/lib/validierung/substanz'
const REIHENFOLGE: ImmobilienStatusSozialhilfe[] = [
'UNGEKLAERT',
'SELBST_BEWOHNT_GESCHUETZT',
'HAERTEFALL_GELTEND_GEMACHT',
'DARLEHEN_91_SGB_XII',
'VERKAUF_LAEUFT',
'VERWERTET',
]
export function StatusSozialhilfe({
betreuteId,
immobilieId,
status,
begruendung,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
status: ImmobilienStatusSozialhilfe
begruendung: string | null
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
statusSozialhilfeSpeichern,
{},
)
const [gewaehlt, setGewaehlt] = useState<ImmobilienStatusSozialhilfe>(status)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="size-5" aria-hidden="true" />
Status im Sozialhilfeverfahren
</CardTitle>
<CardDescription>
Wie das Grundstück gegenüber dem Bezirk behandelt wird — und warum. Die Begründung
ist deine Argumentation, keine Rechtsauskunft.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{darfSchreiben ? (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<Feld
id="statusSozialhilfe"
beschriftung="Einordnung"
erklaerung={IMMOBILIEN_STATUS_ERKLAERUNG[gewaehlt]}
fehler={zustand.felder?.statusSozialhilfe}
pflicht
>
<Select
name="statusSozialhilfe"
value={gewaehlt}
onChange={(e) => setGewaehlt(e.target.value as ImmobilienStatusSozialhilfe)}
>
{REIHENFOLGE.map((wert) => (
<option key={wert} value={wert}>
{IMMOBILIEN_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="begruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Nenne Datum und Gesprächspartner, wenn die Einordnung auf einer Auskunft des Bezirks beruht."
fehler={zustand.felder?.begruendung}
pflicht={gewaehlt !== 'UNGEKLAERT'}
>
<Textarea name="begruendung" defaultValue={begruendung ?? ''} rows={5} maxLength={4000} />
</Feld>
<Absendeknopf>Status speichern</Absendeknopf>
</form>
) : (
<div className="space-y-2">
<p className="font-medium">{IMMOBILIEN_STATUS_BESCHRIFTUNG[status]}</p>
<p className="text-sm text-muted-foreground">
{IMMOBILIEN_STATUS_ERKLAERUNG[status]}
</p>
{begruendung && <p className="whitespace-pre-line text-sm">{begruendung}</p>}
</div>
)}
<Rechtshinweis rechtsgrundlage={IMMOBILIEN_STATUS_RECHTSGRUNDLAGE} />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: § 90 Abs. 2 Nr. 8 SGB XII schützt ein angemessenes Hausgrundstück,
solange die betreute Person es selbst bewohnt. Nach einem dauerhaften Heimeinzug
sehen die Träger diesen Schutz regelmäßig als entfallen an. Dann bleibt § 91 SGB XII:
Die Hilfe wird als Darlehen gewährt und in der Regel durch eine Grundschuld
gesichert, statt sofort zum Verkauf zu zwingen. Ob im Einzelfall eine Härte nach
§ 90 Abs. 3 SGB XII vorliegt, entscheidet weder diese Anwendung noch der Sachbearbeiter
am Telefon — das gehört schriftlich beantragt und im Zweifel überprüft.
</p>
</CardContent>
</Card>
)
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useActionState, useState } from 'react'
import { LineChart, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Leerzustand } from '@/components/seiten-kopf'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { WERTQUELLEN_IMMOBILIE, WERTQUELLE_ERKLAERUNG } from '@/lib/immobilie/konstanten'
import { wertstandErfassen } from '@/server/actions/immobilie'
import { formatCent, formatDatum } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type WertZeile = {
id: string
stichtag: string
wertCent: number
wertquelle: string
bemerkung: string | null
}
export function WertHistorie({
betreuteId,
immobilieId,
werte,
darfSchreiben,
}: {
betreuteId: string
immobilieId: string
werte: WertZeile[]
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(wertstandErfassen, {})
const [offen, setOffen] = useState(false)
const [quelle, setQuelle] = useState<string>(WERTQUELLEN_IMMOBILIE[0])
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LineChart className="size-5" aria-hidden="true" />
Wert-Historie
</CardTitle>
<CardDescription>
Jeder Wert bekommt einen Stichtag und eine Quelle. Nichts wird überschrieben — jede
Vermögensübersicht muss Jahre später noch nachvollziehbar sein.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{werte.length === 0 ? (
<Leerzustand
titel="Noch kein Wert hinterlegt"
beschreibung="Der Bodenrichtwert ist kostenlos und schnell zu bekommen (BORIS Bayern, Gutachterausschuss). Er ist keine Bewertung, aber ein belastbarer Anfang."
icon={LineChart}
aktion={
darfSchreiben && (
<Button type="button" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Wert eintragen
</Button>
)
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Stichtag</TableHead>
<TableHead className="text-right">Wert</TableHead>
<TableHead>Quelle</TableHead>
<TableHead>Bemerkung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{werte.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell className="whitespace-nowrap">{formatDatum(zeile.stichtag)}</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatCent(zeile.wertCent)}
</TableCell>
<TableCell>{zeile.wertquelle}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{zeile.bemerkung ?? '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{darfSchreiben && (offen || werte.length > 0) && (
<div className="rounded-md border p-4">
{!offen ? (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Weiteren Wert eintragen
</Button>
) : (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="immobilieId" value={immobilieId} />
<Zustandsmeldung zustand={zustand} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="wert-stichtag" beschriftung="Stichtag" fehler={zustand.felder?.stichtag} pflicht>
<Input name="stichtag" type="date" required />
</Feld>
<Feld
id="wert-betrag"
beschriftung="Wert"
erklaerung="Der Wert des gesamten Objekts, nicht des Miteigentumsanteils."
fehler={zustand.felder?.wert}
pflicht
>
<Input name="wert" inputMode="decimal" placeholder="185.000,00" required />
</Feld>
<Feld
id="wert-quelle"
beschriftung="Wertquelle"
erklaerung={WERTQUELLE_ERKLAERUNG[quelle as keyof typeof WERTQUELLE_ERKLAERUNG]}
fehler={zustand.felder?.wertquelle}
pflicht
className="sm:col-span-2"
>
<Select name="wertquelle" value={quelle} onChange={(e) => setQuelle(e.target.value)}>
{WERTQUELLEN_IMMOBILIE.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</Select>
</Feld>
</div>
<Feld id="wert-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea
name="bemerkung"
rows={2}
maxLength={1000}
placeholder="z. B. Gutachterausschuss Bayreuth, Richtwertzone 12, 210 €/m²"
/>
</Feld>
<div className="flex flex-wrap gap-2">
<Absendeknopf>Wert aufnehmen</Absendeknopf>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)}
</div>
)}
</CardContent>
</Card>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type {
BescheidPruefStatus,
LeistungStatus,
NachforderungStatus,
SozialhilfeBewertung,
} from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { PRUEFSTATUS_BESCHRIFTUNG, PRUEFSTATUS_VARIANTE } from '@/lib/sozialhilfe/bescheide'
import {
LEISTUNG_STATUS_BESCHRIFTUNG,
LEISTUNG_STATUS_VARIANTE,
} from '@/lib/sozialhilfe/leistungskatalog'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_VARIANTE,
} from '@/lib/sozialhilfe/nachforderungen'
import { BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_VARIANTE } from '@/lib/sozialhilfe/schonvermoegen'
/**
* Statuskennzeichen des Moduls.
*
* Alle tragen Text, nicht nur Farbe - wer rot und grün nicht unterscheidet,
* soll trotzdem sehen, woran er ist.
*/
export function NachforderungAbzeichen({ status }: { status: NachforderungStatus }) {
return <Badge variant={NACHFORDERUNG_VARIANTE[status]}>{NACHFORDERUNG_BESCHRIFTUNG[status]}</Badge>
}
export function BescheidAbzeichen({ status }: { status: BescheidPruefStatus }) {
return <Badge variant={PRUEFSTATUS_VARIANTE[status]}>{PRUEFSTATUS_BESCHRIFTUNG[status]}</Badge>
}
export function BewertungAbzeichen({ bewertung }: { bewertung: SozialhilfeBewertung }) {
return <Badge variant={BEWERTUNG_VARIANTE[bewertung]}>{BEWERTUNG_BESCHRIFTUNG[bewertung]}</Badge>
}
export function LeistungAbzeichen({ status }: { status: LeistungStatus }) {
return (
<Badge variant={LEISTUNG_STATUS_VARIANTE[status]}>{LEISTUNG_STATUS_BESCHRIFTUNG[status]}</Badge>
)
}
/** Freie Ampel für berechnete Lagen (Fristen, Deckungslücke). */
export function AmpelAbzeichen({
variante,
text,
}: {
variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
text: string
}) {
return <Badge variant={variante}>{text}</Badge>
}
@@ -0,0 +1,168 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { antragSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type AntragWerte = {
id: string | null
traeger: string
leistungsart: string
aktenzeichen: string
sachbearbeiter: string
telefon: string
email: string
antragAm: string
eingangBeimTraegerAm: string
bewilligtVon: string
bewilligtBis: string
notizen: string
}
/**
* Die Antragsakte: wer bearbeitet den Fall, unter welchem Aktenzeichen,
* seit wann und für welchen Zeitraum bewilligt.
*
* Mehrere Anträge nebeneinander sind der Normalfall - Erstantrag,
* Weiterbewilligung, dazu vielleicht ein eigener Antrag auf Grundsicherung.
* Jeder bekommt seine eigene Akte mit eigenen Nachforderungen und
* Bescheiden.
*/
export function AntragFormular({ werte }: { werte: AntragWerte }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(antragSpeichern, {})
return (
<form action={aktion} className="space-y-6">
{werte.id && <input type="hidden" name="antragId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-6 pt-5">
<Feldgruppe
titel="Träger und Leistung"
beschreibung="In Bayern ist für Leistungen in stationären Einrichtungen der Bezirk zuständig, nicht der Landkreis."
>
<Feld
id="traeger"
beschriftung="Träger"
pflicht
fehler={zustand.felder?.traeger}
erklaerung="Wer entscheidet über den Antrag?"
>
<Input name="traeger" defaultValue={werte.traeger} required />
</Feld>
<Feld
id="leistungsart"
beschriftung="Leistungsart"
pflicht
fehler={zustand.felder?.leistungsart}
erklaerung="z.B. Hilfe zur Pflege nach dem 7. Kapitel SGB XII"
>
<Input name="leistungsart" defaultValue={werte.leistungsart} required />
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichen}
erklaerung="Steht auf jedem Schreiben des Trägers. Gehört in jede Rückmeldung."
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Sachbearbeitung"
beschreibung="Ein Anruf klärt oft schneller als drei Briefe. Deshalb gehört die Durchwahl hierher."
>
<Feld
id="sachbearbeiter"
beschriftung="Sachbearbeiter/in"
fehler={zustand.felder?.sachbearbeiter}
>
<Input name="sachbearbeiter" defaultValue={werte.sachbearbeiter} />
</Feld>
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
<Input name="telefon" type="tel" defaultValue={werte.telefon} />
</Feld>
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
<Input name="email" type="email" defaultValue={werte.email} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Verfahrensdaten"
beschreibung="Das Eingangsdatum beim Träger ist wichtig: Leistungen setzen ab Kenntnis des Trägers ein (§ 18 SGB XII), nicht erst ab der Bewilligung."
>
<Feld
id="antragAm"
beschriftung="Antrag gestellt am"
fehler={zustand.felder?.antragAm}
erklaerung="Datum auf deinem Antragsschreiben."
>
<Input name="antragAm" type="date" defaultValue={werte.antragAm} />
</Feld>
<Feld
id="eingangBeimTraegerAm"
beschriftung="Eingang beim Träger"
fehler={zustand.felder?.eingangBeimTraegerAm}
erklaerung="Ab wann wusste der Träger Bescheid? Eingangsstempel oder Sendebericht aufheben."
>
<Input
name="eingangBeimTraegerAm"
type="date"
defaultValue={werte.eingangBeimTraegerAm}
/>
</Feld>
<Feld
id="bewilligtVon"
beschriftung="Bewilligt von"
fehler={zustand.felder?.bewilligtVon}
>
<Input name="bewilligtVon" type="date" defaultValue={werte.bewilligtVon} />
</Feld>
<Feld
id="bewilligtBis"
beschriftung="Bewilligt bis"
fehler={zustand.felder?.bewilligtBis}
erklaerung="Vor dem Ablauf rechtzeitig die Weiterbewilligung beantragen — sonst reißt die Zahlung ab."
>
<Input name="bewilligtBis" type="date" defaultValue={werte.bewilligtBis} />
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was am Telefon besprochen wurde, gehört hierher."
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Antragsakte anlegen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={werte.id ? `/sozialhilfe/${werte.id}` : '/sozialhilfe'}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button, type ButtonProps } from '@/components/ui/button'
import type { Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Kleinteile, die in allen Formularen des Sozialhilfemoduls
* wiederkehren. Sie liegen hier zusammen, damit nicht jedes Formular
* seinen eigenen Absende-Knopf und seine eigene Meldung erfindet.
*/
/**
* Absende-Knopf, der während des Speicherns gesperrt ist. Ohne die Sperre
* entstehen bei langsamer Verbindung Dubletten - und eine doppelt
* angelegte Nachforderung fällt erst beim Bezirk auf.
*/
export function Absenden({
children = 'Speichern',
laedtText = 'Wird gespeichert …',
disabled,
...props
}: ButtonProps & { laedtText?: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" {...props} disabled={pending || disabled}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? laedtText : children}
</Button>
)
}
/** Rückmeldung einer Server Action - Fehler oder Erfolg. */
export function Meldung({ zustand }: { zustand: Formularzustand }) {
if (zustand.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)
}
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)
}
return null
}
/**
* Ein Knopf, der eine Aktion ohne Formularfelder auslöst und vorher
* nachfragt. Für Löschen und andere Schritte, die man nicht versehentlich
* gehen will.
*/
export function AktionMitRueckfrage({
aktion,
frage,
children,
variant = 'ghost',
size = 'sm',
}: {
aktion: () => Promise<void>
frage: string
children: React.ReactNode
variant?: ButtonProps['variant']
size?: ButtonProps['size']
}) {
return (
<form
action={aktion}
onSubmit={(ereignis) => {
if (!window.confirm(frage)) ereignis.preventDefault()
}}
>
<Absenden variant={variant} size={size} laedtText="Einen Moment …">
{children}
</Absenden>
</form>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import type { BescheidArt } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BESCHEID_ART_BESCHRIFTUNG, BESCHEID_ART_ERKLAERUNG, BESCHEID_ARTEN } from '@/lib/sozialhilfe/bescheide'
import { BELEHRUNG_ERKLAERUNG } from '@/lib/sozialhilfe/rechtsbehelf'
import { bescheidSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
import { Info } from 'lucide-react'
export type BescheidWerte = {
id: string | null
art: BescheidArt
bescheidVom: string
zugegangenAm: string
zeitraumVon: string
zeitraumBis: string
betrag: string
rechtsbehelfsfristMonate: number
widerspruchEingelegtAm: string
notizen: string
}
export const LEERER_BESCHEID: BescheidWerte = {
id: null,
art: 'BEWILLIGUNG',
bescheidVom: '',
zugegangenAm: '',
zeitraumVon: '',
zeitraumBis: '',
betrag: '',
rechtsbehelfsfristMonate: 1,
widerspruchEingelegtAm: '',
notizen: '',
}
/**
* Bescheid erfassen.
*
* Das wichtigste Feld ist das Zugangsdatum, nicht das Datum auf dem
* Bescheid. Die Widerspruchsfrist läuft ab Bekanntgabe (§ 84 Abs. 1 SGG) -
* zwischen Ausstellung und Zugang liegen fast immer mehrere Tage, und wer
* vom falschen Tag aus rechnet, verschenkt sie.
*/
export function BescheidFormular({
antragId,
werte,
zugangsvorschlag,
}: {
antragId: string
werte: BescheidWerte
/** Vorschlag nach § 37 Abs. 2 SGB X, sobald ein Bescheiddatum steht. */
zugangsvorschlag?: { datum: string; hinweis: string } | null
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidSpeichern, {})
const [art, setArt] = useState<BescheidArt>(werte.art)
const [monate, setMonate] = useState(String(werte.rechtsbehelfsfristMonate))
const [zugegangenAm, setZugegangenAm] = useState(werte.zugegangenAm)
return (
<form action={aktion} className="space-y-6">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="bescheidId" value={werte.id} />}
<Meldung zustand={zustand} />
<Card>
<CardContent className="space-y-5 pt-5">
<Feld
id="art"
beschriftung="Art des Bescheids"
pflicht
fehler={zustand.felder?.art}
erklaerung={BESCHEID_ART_ERKLAERUNG[art]}
>
<Select name="art" value={art} onChange={(e) => setArt(e.target.value as BescheidArt)}>
{BESCHEID_ARTEN.map((a) => (
<option key={a} value={a}>
{BESCHEID_ART_BESCHRIFTUNG[a]}
</option>
))}
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="bescheidVom"
beschriftung="Bescheid vom"
pflicht
fehler={zustand.felder?.bescheidVom}
erklaerung="Das Datum, das oben auf dem Schreiben steht."
>
<Input name="bescheidVom" type="date" defaultValue={werte.bescheidVom} required />
</Feld>
<Feld
id="zugegangenAm"
beschriftung="Zugegangen am"
fehler={zustand.felder?.zugegangenAm}
erklaerung="Maßgeblich für den Fristbeginn — nicht das Datum auf dem Bescheid. Hebe den Briefumschlag mit dem Poststempel auf."
>
<Input
name="zugegangenAm"
type="date"
value={zugegangenAm}
onChange={(e) => setZugegangenAm(e.target.value)}
/>
</Feld>
</div>
{zugangsvorschlag && zugegangenAm === '' && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertTitle>Zugangsvermutung</AlertTitle>
<AlertDescription className="space-y-2">
<p>{zugangsvorschlag.hinweis}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setZugegangenAm(zugangsvorschlag.datum)}
>
{zugangsvorschlag.datum.split('-').reverse().join('.')} übernehmen
</Button>
</AlertDescription>
</Alert>
)}
<Feld
id="rechtsbehelfsfristMonate"
beschriftung="Rechtsbehelfsbelehrung"
fehler={zustand.felder?.rechtsbehelfsfristMonate}
erklaerung={
BELEHRUNG_ERKLAERUNG[monate === '12' ? 'FEHLT_ODER_FEHLERHAFT' : 'ORDNUNGSGEMAESS']
}
>
<Select
name="rechtsbehelfsfristMonate"
value={monate}
onChange={(e) => setMonate(e.target.value)}
>
<option value="1">Vorhanden und richtig — ein Monat (§ 84 SGG)</option>
<option value="12">Fehlt oder ist fehlerhaft — ein Jahr (§ 66 SGG)</option>
</Select>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="zeitraumVon"
beschriftung="Zeitraum von"
fehler={zustand.felder?.zeitraumVon}
>
<Input name="zeitraumVon" type="date" defaultValue={werte.zeitraumVon} />
</Feld>
<Feld id="zeitraumBis" beschriftung="Zeitraum bis" fehler={zustand.felder?.zeitraumBis}>
<Input name="zeitraumBis" type="date" defaultValue={werte.zeitraumBis} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="Monatlicher oder einmaliger Betrag laut Bescheid, z.B. 1.234,56"
>
<Input name="betrag" inputMode="decimal" defaultValue={werte.betrag} />
</Feld>
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Nur ausfüllen, wenn der Widerspruch bereits raus ist."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={werte.widerspruchEingelegtAm}
/>
</Feld>
</div>
<Feld
id="notizen"
beschriftung="Notizen"
fehler={zustand.felder?.notizen}
erklaerung="Wird verschlüsselt gespeichert. Was ist auffällig? Welche Position stimmt nicht?"
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
</Feld>
{zugegangenAm !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" defaultChecked />
<span>
Frist „Widerspruch möglich bis“ im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Mit dem berechneten Fristende und Vorwarnung 21, 14, 7, 3 und 1 Tag vorher. Der
Rechenweg steht in der Beschreibung der Frist.
</span>
</span>
</label>
)}
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 66 SGG" />
<div className="flex flex-wrap gap-2">
<Absenden>{werte.id ? 'Änderungen speichern' : 'Bescheid erfassen'}</Absenden>
<Button variant="ghost" asChild>
<Link href={`/sozialhilfe/${antragId}`}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
@@ -0,0 +1,200 @@
'use client'
import { useActionState, useState } from 'react'
import type { BescheidPruefStatus } from '@prisma/client'
import { Scale } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
PRUEFSTATUS,
PRUEFSTATUS_BESCHRIFTUNG,
PRUEFSTATUS_ERKLAERUNG,
} from '@/lib/sozialhilfe/bescheide'
import { bescheidStatusSetzen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die Prüfansicht eines Bescheids: Rechenweg der Widerspruchsfrist,
* Ampel und Statuswechsel.
*
* Der Rechenweg steht ausgeschrieben da, weil die Anwendung sich irren
* kann. Wer eine Frist verpasst, soll sehen, wo die Rechnung abgebogen
* ist - und nicht einer Zahl vertrauen müssen, die aus dem Nichts kommt.
*/
export type Rechenweg = {
berechenbar: boolean
widerspruchMoeglichBis: string | null
rechnerischesEnde: string | null
verschoben: boolean
tageVerbleibend: number | null
abgelaufen: boolean
ampel: { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string }
schritte: { titel: string; text: string; rechtsgrundlage: string }[]
hinweise: string[]
}
function alsAnzeige(iso: string | null): string {
return iso ? iso.split('-').reverse().join('.') : '–'
}
export function BescheidPruefung({
bescheidId,
pruefStatus,
widerspruchEingelegtAm,
rechenweg,
darfSchreiben,
}: {
bescheidId: string
pruefStatus: BescheidPruefStatus
widerspruchEingelegtAm: string
rechenweg: Rechenweg
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidStatusSetzen, {})
const [status, setStatus] = useState<BescheidPruefStatus>(pruefStatus)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Widerspruchsfrist</CardTitle>
<CardDescription>
Berechnet aus dem Zugangsdatum. Die Anwendung rechnet — entscheiden tun der Träger
und im Streitfall das Sozialgericht.
</CardDescription>
</div>
<Badge variant={rechenweg.ampel.variante}>{rechenweg.ampel.text}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{rechenweg.berechenbar ? (
<>
<div className="rounded-lg border bg-muted/40 p-4">
<p className="text-sm text-muted-foreground">Widerspruch möglich bis</p>
<p className="text-2xl font-semibold tabular-nums">
{alsAnzeige(rechenweg.widerspruchMoeglichBis)}
</p>
{rechenweg.verschoben && (
<p className="mt-1 text-sm text-muted-foreground">
Rechnerisch wäre es der {alsAnzeige(rechenweg.rechnerischesEnde)} gewesen —
verschoben auf den nächsten Werktag.
</p>
)}
</div>
<ol className="space-y-3">
{rechenweg.schritte.map((schritt, i) => (
<li key={i} className="flex gap-3">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
aria-hidden="true"
>
{i + 1}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{schritt.titel}</p>
<p className="text-sm text-muted-foreground">{schritt.text}</p>
<p className="mt-0.5 text-xs font-medium text-muted-foreground">
{schritt.rechtsgrundlage}
</p>
</div>
</li>
))}
</ol>
</>
) : (
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>Frist noch nicht berechenbar</AlertTitle>
<AlertDescription>
Ohne Zugangsdatum lässt sich nichts rechnen. Trag oben ein, wann der Bescheid im
Briefkasten lag.
</AlertDescription>
</Alert>
)}
{rechenweg.hinweise.map((hinweis, i) => (
<Alert key={i} variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>{hinweis}</AlertDescription>
</Alert>
))}
<Rechtshinweis rechtsgrundlage="§ 84 SGG" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Prüfstand</CardTitle>
<CardDescription>{PRUEFSTATUS_ERKLAERUNG[pruefStatus]}</CardDescription>
</div>
<BescheidAbzeichen status={pruefStatus} />
</div>
</CardHeader>
<CardContent>
{darfSchreiben ? (
<form action={aktion} className="space-y-4">
<input type="hidden" name="bescheidId" value={bescheidId} />
<Meldung zustand={zustand} />
<Feld
id="pruefStatus"
beschriftung="Neuer Stand"
fehler={zustand.felder?.pruefStatus}
erklaerung={PRUEFSTATUS_ERKLAERUNG[status]}
>
<Select
name="pruefStatus"
value={status}
onChange={(e) => setStatus(e.target.value as BescheidPruefStatus)}
>
{PRUEFSTATUS.map((s) => (
<option key={s} value={s}>
{PRUEFSTATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
{status === 'WIDERSPRUCH_EINGELEGT' && (
<Feld
id="widerspruchEingelegtAm"
beschriftung="Widerspruch eingelegt am"
pflicht
fehler={zustand.felder?.widerspruchEingelegtAm}
erklaerung="Der Tag, an dem der Widerspruch beim Träger eingegangen ist — nicht der Tag, an dem du ihn geschrieben hast."
>
<Input
name="widerspruchEingelegtAm"
type="date"
defaultValue={widerspruchEingelegtAm}
/>
</Feld>
)}
<Absenden variant="outline">Stand übernehmen</Absenden>
</form>
) : (
<p className="text-sm text-muted-foreground">
Zum Ändern des Prüfstands fehlt dir die Schreibberechtigung.
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,180 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung } from '@prisma/client'
import { BookOpen, ChevronDown } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Argumentationshilfe } from '@/lib/sozialhilfe/schonvermoegen'
import { ALLE_BEWERTUNGEN, BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { bewertungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Bewertung eines Vermögenspostens oder Vertrags im Sozialhilfeverfahren.
*
* Die Argumentationshilfen sind Textbausteine zum Übernehmen, keine
* Automatik: Die Anwendung setzt nie selbst eine Bewertung. Sie schlägt
* eine Richtung vor, liefert die Fundstelle und den Formulierungsvorschlag
* - eingeordnet wird von Hand, und über die Anrechnung entscheidet
* ohnehin der Träger.
*/
export function BewertungFormular({
herkunft,
id,
bezeichnung,
bewertung,
begruendung,
hilfen,
darfSchreiben,
beimSpeichern,
}: {
herkunft: 'VERMOEGEN' | 'VERTRAG'
id: string
bezeichnung: string
bewertung: SozialhilfeBewertung
begruendung: string
hilfen: Argumentationshilfe[]
darfSchreiben: boolean
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await bewertungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [auswahl, setAuswahl] = useState<SozialhilfeBewertung>(bewertung)
const [text, setText] = useState(begruendung)
const [offeneHilfe, setOffeneHilfe] = useState<string | null>(null)
if (!darfSchreiben) {
return (
<div className="space-y-2">
<p className="text-sm">
<span className="font-medium">{BEWERTUNG_BESCHRIFTUNG[bewertung]}</span>
</p>
{begruendung ? (
<p className="whitespace-pre-line text-sm text-muted-foreground">{begruendung}</p>
) : (
<p className="text-sm text-muted-foreground">Keine Begründung hinterlegt.</p>
)}
</div>
)
}
const uebernehmen = (hilfe: Argumentationshilfe) => {
setText((vorher) => (vorher.trim() === '' ? hilfe.textbaustein : `${vorher}\n\n${hilfe.textbaustein}`))
setAuswahl((vorher) => (vorher === 'UNGEPRUEFT' ? hilfe.vorschlag : vorher))
}
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="herkunft" value={herkunft} />
<input type="hidden" name="id" value={id} />
<Meldung zustand={zustand} />
<Feld
id={`bewertung-${id}`}
beschriftung={`Einschätzung zu „${bezeichnung}“`}
fehler={zustand.felder?.bewertung}
erklaerung={BEWERTUNG_ERKLAERUNG[auswahl]}
>
<Select
name="bewertung"
value={auswahl}
onChange={(e) => setAuswahl(e.target.value as SozialhilfeBewertung)}
>
{ALLE_BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id={`begruendung-${id}`}
beschriftung="Begründung"
fehler={zustand.felder?.begruendung}
erklaerung="Wird verschlüsselt gespeichert. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter — und in einem halben Jahr weißt du selbst nicht mehr, warum."
>
<Textarea
name="begruendung"
value={text}
onChange={(e) => setText(e.target.value)}
rows={8}
/>
</Feld>
{hilfen.length > 0 && (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<BookOpen className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">Argumentationshilfen</span>
</div>
<ul className="divide-y">
{hilfen.map((hilfe) => (
<li key={hilfe.schluessel} className="px-4 py-3">
<button
type="button"
onClick={() =>
setOffeneHilfe((v) => (v === hilfe.schluessel ? null : hilfe.schluessel))
}
aria-expanded={offeneHilfe === hilfe.schluessel}
className="flex w-full items-start justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block text-sm font-medium">{hilfe.titel}</span>
<span className="block text-sm text-muted-foreground">{hilfe.kurz}</span>
<span className="mt-0.5 block text-xs font-medium text-muted-foreground">
{hilfe.rechtsgrundlage}
</span>
</span>
<ChevronDown
className={`mt-1 size-4 shrink-0 transition-transform ${
offeneHilfe === hilfe.schluessel ? 'rotate-180' : ''
}`}
aria-hidden="true"
/>
</button>
{offeneHilfe === hilfe.schluessel && (
<div className="mt-3 space-y-3">
<div className="rounded-md border border-ampel-orange/40 bg-ampel-orange/10 p-3">
<p className="text-xs font-semibold uppercase tracking-wide">
Worauf zu achten ist
</p>
<p className="mt-1 text-sm">{hilfe.worauf_achten}</p>
</div>
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-3 font-sans text-sm">
{hilfe.textbaustein}
</pre>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => uebernehmen(hilfe)}
>
In die Begründung übernehmen
</Button>
</div>
)}
</li>
))}
</ul>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" />
<Absenden>Bewertung speichern</Absenden>
</form>
)
}
@@ -0,0 +1,143 @@
'use client'
import { useActionState, useState } from 'react'
import { ListChecks } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import type { Checklistenposition } from '@/lib/sozialhilfe/checkliste'
import { checklisteUebernehmen, type Formularzustand } from '@/server/actions/sozialhilfe'
/**
* Die vorbefüllte Checkliste der Standardunterlagen.
*
* Zu jeder Position steht, warum der Träger sie will. Das ist der eigentliche
* Wert dieser Liste: Wer den Zweck kennt, beschafft das Richtige beim ersten
* Mal - und erkennt, wenn eine Nachforderung über das Nötige hinausgeht.
*/
export function ChecklistePanel({
antragId,
offen,
bereitsUebernommen,
}: {
antragId: string
offen: Checklistenposition[]
bereitsUebernommen: number
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checklisteUebernehmen, {})
const [ausgewaehlt, setAusgewaehlt] = useState<Set<string>>(new Set())
const umschalten = (schluessel: string) => {
setAusgewaehlt((vorher) => {
const neu = new Set(vorher)
if (neu.has(schluessel)) neu.delete(schluessel)
else neu.add(schluessel)
return neu
})
}
const alleWaehlen = () => {
setAusgewaehlt((vorher) =>
vorher.size === offen.length ? new Set() : new Set(offen.map((p) => p.schluessel)),
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListChecks className="size-5" aria-hidden="true" />
Standardunterlagen
</CardTitle>
<CardDescription>
Diesen Stapel fordert der Bezirk bei einem Antrag auf Hilfe zur Pflege fast immer an. Wer
ihn vorher beschafft, spart Wochen.{' '}
{bereitsUebernommen > 0 && (
<>
{bereitsUebernommen} von {bereitsUebernommen + offen.length} stehen schon im Tracker.
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{offen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Alle Standardunterlagen stehen bereits im Tracker. Weitere Anforderungen des Trägers
trägst du dort einzeln nach.
</p>
) : (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
<Meldung zustand={zustand} />
<button
type="button"
onClick={alleWaehlen}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{ausgewaehlt.size === offen.length ? 'Auswahl aufheben' : 'Alle auswählen'}
</button>
<ul className="max-h-[28rem] space-y-2 overflow-y-auto rounded-lg border p-2">
{offen.map((position) => (
<li key={position.schluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-accent/50">
<Checkbox
name="schluessel"
value={position.schluessel}
checked={ausgewaehlt.has(position.schluessel)}
onChange={() => umschalten(position.schluessel)}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium">{position.bezeichnung}</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
{position.erlaeuterung}
</span>
{position.rechtsgrundlage && (
<span className="mt-1 block text-xs font-medium text-muted-foreground">
{position.rechtsgrundlage}
</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="checkliste-angefordertAm"
beschriftung="Angefordert am"
erklaerung="Datum des Anforderungsschreibens — gilt für alle ausgewählten Positionen."
>
<Input name="angefordertAm" type="date" />
</Feld>
<Feld
id="checkliste-fristBis"
beschriftung="Frist des Trägers"
erklaerung="Optional. Kannst du später je Zeile anpassen."
>
<Input name="fristBis" type="date" />
</Feld>
</div>
<Absenden disabled={ausgewaehlt.size === 0}>
{ausgewaehlt.size === 0
? 'Nichts ausgewählt'
: `${ausgewaehlt.size} ${
ausgewaehlt.size === 1 ? 'Unterlage' : 'Unterlagen'
} übernehmen`}
</Absenden>
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</form>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,326 @@
'use client'
import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = {
jahr: number
monat: number
pflegeEigenanteil: string
unterkunftVerpflegung: string
investitionskosten: string
ausbildungsumlage: string
zusatzleistungen: string
pflegekasse: string
leistungszuschlagProzent: number
leistungszuschlag: string
rente: string
sonstigeEinkuenfte: string
sozialhilfe: string
eigenmittel: string
barbetrag: string
bekleidungspauschale: string
abwesenheitstage: number
bemerkung: string
}
/**
* Ein Monat der Gegenüberstellung.
*
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht.
*/
export function HeimkostenFormular({
werte,
jahre,
vorschlagPflegekasse,
vorschlagBarbetrag,
}: {
werte: HeimkostenWerte
jahre: number[]
/** Pauschale der Pflegekasse zum hinterlegten Pflegegrad, als "1319,00". */
vorschlagPflegekasse: { betrag: string; text: string } | null
vorschlagBarbetrag: { betrag: string; text: string }
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [pflegekasse, setPflegekasse] = useState(werte.pflegekasse)
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return (
<form action={aktion} className="space-y-6">
<Meldung zustand={zustand} />
<Card>
<CardHeader>
<CardTitle>Monat erfassen</CardTitle>
<CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => (
<option key={j} value={j}>
{j}
</option>
))}
</Select>
</Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</Select>
</Feld>
</div>
<Feldgruppe
titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
>
<Feld
id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
>
<Input
name="pflegeEigenanteil"
inputMode="decimal"
defaultValue={werte.pflegeEigenanteil}
/>
</Feld>
<Feld
id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung}
>
<Input
name="unterkunftVerpflegung"
inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung}
/>
</Feld>
<Feld
id="investitionskosten"
beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
>
<Input
name="investitionskosten"
inputMode="decimal"
defaultValue={werte.investitionskosten}
/>
</Feld>
<Feld
id="ausbildungsumlage"
beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage}
>
<Input
name="ausbildungsumlage"
inputMode="decimal"
defaultValue={werte.ausbildungsumlage}
/>
</Feld>
<Feld
id="zusatzleistungen"
beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
>
<Input
name="zusatzleistungen"
inputMode="decimal"
defaultValue={werte.zusatzleistungen}
/>
</Feld>
</Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld
id="pflegekasse"
beschriftung="Leistung der Pflegekasse"
fehler={zustand.felder?.pflegekasse}
erklaerung={
vorschlagPflegekasse ? (
<>
{vorschlagPflegekasse.text}{' '}
<button
type="button"
onClick={() => setPflegekasse(vorschlagPflegekasse.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
) : (
'Pauschale nach § 43 SGB XI. Hinterlege den Pflegegrad in den Stammdaten, dann steht hier ein Vorschlag.'
)
}
>
<Input
name="pflegekasse"
inputMode="decimal"
value={pflegekasse}
onChange={(e) => setPflegekasse(e.target.value)}
/>
</Feld>
<Feld
id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
>
<Input
name="leistungszuschlagProzent"
type="number"
min={0}
max={100}
defaultValue={werte.leistungszuschlagProzent}
/>
</Feld>
<Feld
id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
>
<Input
name="leistungszuschlag"
inputMode="decimal"
defaultValue={werte.leistungszuschlag}
/>
</Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld>
<Feld
id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
>
<Input
name="sonstigeEinkuenfte"
inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte}
/>
</Feld>
<Feld
id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt."
>
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld>
<Feld
id="eigenmittel"
beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
>
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
>
<Feld
id="barbetrag"
beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag}
erklaerung={
<>
{vorschlagBarbetrag.text}{' '}
<button
type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline"
>
Wert übernehmen
</button>
</>
}
>
<Input
name="barbetrag"
inputMode="decimal"
value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)}
/>
</Feld>
<Feld
id="bekleidungspauschale"
beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
>
<Input
name="bekleidungspauschale"
inputMode="decimal"
defaultValue={werte.bekleidungspauschale}
/>
</Feld>
<Feld
id="abwesenheitstage"
beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
>
<Input
name="abwesenheitstage"
type="number"
min={0}
max={31}
defaultValue={werte.abwesenheitstage}
/>
</Feld>
</Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld>
</CardContent>
</Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden>
</form>
)
}
@@ -0,0 +1,85 @@
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCent } from '@/lib/utils'
import { monatsname } from '@/lib/sozialhilfe/heimkosten'
/**
* Jahresverlauf der Gegenüberstellung.
*
* Server Component: Die Beträge bleiben BigInt bis zur Anzeige, es geht
* nichts über die Grenze zum Browser. Die Tabelle scrollt in ihrem eigenen
* Rahmen - am Handy darf nie die ganze Seite seitlich wandern.
*/
export type MonatsZeile = {
jahr: number
monat: number
heimkostenCent: bigint
gesamtbedarfCent: bigint
deckungCent: bigint
deckungslueckeCent: bigint
ueberdeckungCent: bigint
abwesenheitstage: number
erfasst: boolean
}
export function HeimkostenTabelle({ zeilen }: { zeilen: MonatsZeile[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Monat</TableHead>
<TableHead className="text-right">Heimkosten</TableHead>
<TableHead className="text-right">Gesamtbedarf</TableHead>
<TableHead className="text-right">Deckung</TableHead>
<TableHead className="text-right">Ergebnis</TableHead>
<TableHead className="text-right">Abwesend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<TableRow key={`${zeile.jahr}-${zeile.monat}`}>
<TableCell className="font-medium">
{monatsname(zeile.monat)}
{!zeile.erfasst && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
nicht erfasst
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.heimkostenCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.gesamtbedarfCent) : '–'}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? formatCent(zeile.deckungCent) : '–'}
</TableCell>
<TableCell className="text-right">
{!zeile.erfasst ? (
'–'
) : zeile.deckungslueckeCent > 0n ? (
<Badge variant="rot">Lücke {formatCent(zeile.deckungslueckeCent)}</Badge>
) : zeile.ueberdeckungCent > 0n ? (
<Badge variant="gruen">Überschuss {formatCent(zeile.ueberdeckungCent)}</Badge>
) : (
<Badge variant="gruen">gedeckt</Badge>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{zeile.erfasst ? zeile.abwesenheitstage : '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -0,0 +1,298 @@
'use client'
import { useActionState, useState } from 'react'
import type { LeistungStatus } from '@prisma/client'
import { CalendarClock, Plus } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { LeistungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import type { Katalogleistung } from '@/lib/sozialhilfe/leistungskatalog'
import { LEISTUNG_STATUS, LEISTUNG_STATUS_BESCHRIFTUNG } from '@/lib/sozialhilfe/leistungskatalog'
import { formatCent, formatDatum } from '@/lib/utils'
import { katalogUebernehmen, leistungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type LeistungZeile = {
id: string
katalogSchluessel: string | null
bezeichnung: string
traeger: string
rechtsgrundlage: string
status: LeistungStatus
beantragtAm: string | null
bewilligtAm: string | null
gueltigBis: string | null
betragCent: number | null
jaehrlichNeuBeantragen: boolean
notizen: string
/** Aus dem Katalog nachgeschlagen, für den Ausklapp-Bereich. */
katalog: Katalogleistung | null
}
/**
* Katalog weiterer Leistungen mit pflegbarem Status.
*
* Der Katalog steht daneben, damit man nicht raten muss, was man für den
* Antrag braucht. Die jährlich zu wiederholenden Anträge tragen einen
* eigenen Hinweis - sie sind es, die untergehen.
*/
export function LeistungenListe({
leistungen,
offeneKatalogeintraege,
darfSchreiben,
}: {
leistungen: LeistungZeile[]
offeneKatalogeintraege: Katalogleistung[]
darfSchreiben: boolean
}) {
const [katalogZustand, katalogAktion] = useActionState<Formularzustand, FormData>(
katalogUebernehmen,
{},
)
const [offen, setOffen] = useState<string | null>(null)
const jaehrlich = leistungen.filter(
(l) => l.jaehrlichNeuBeantragen && l.status !== 'NICHT_ZUTREFFEND',
)
return (
<div className="space-y-6">
{jaehrlich.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertDescription>
<span className="font-medium">Jährlich neu zu beantragen: </span>
{jaehrlich.map((l) => l.bezeichnung).join(', ')}. Das bayerische Landespflegegeld
verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12. gestellt ist — leg dir dafür
eine Frist an.
</AlertDescription>
</Alert>
)}
{offeneKatalogeintraege.length > 0 && darfSchreiben && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="size-5" aria-hidden="true" />
Aus dem Katalog übernehmen
</CardTitle>
<CardDescription>
Leistungen, die noch nicht in der Liste stehen. Übernommen wird mit Träger,
Rechtsgrundlage und dem Hinweis, was man dafür braucht.
</CardDescription>
</CardHeader>
<CardContent>
<form action={katalogAktion} className="space-y-4">
<Meldung zustand={katalogZustand} />
<ul className="space-y-2">
{offeneKatalogeintraege.map((k) => (
<li key={k.katalogSchluessel}>
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/50">
<Checkbox name="schluessel" value={k.katalogSchluessel} className="mt-1" />
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{k.bezeichnung}</span>
{k.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich</Badge>}
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">{k.kurz}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{k.traeger} · {k.rechtsgrundlage}
</span>
</span>
</label>
</li>
))}
</ul>
<Absenden variant="outline">Ausgewählte übernehmen</Absenden>
</form>
</CardContent>
</Card>
)}
<ul className="space-y-3">
{leistungen.map((l) => (
<li key={l.id}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{l.bezeichnung}</span>
<LeistungAbzeichen status={l.status} />
{l.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich neu</Badge>}
</div>
<p className="text-sm text-muted-foreground">
{l.traeger}
{l.rechtsgrundlage ? ` · ${l.rechtsgrundlage}` : ''}
</p>
<p className="text-sm">
{l.betragCent !== null && (
<span className="font-medium tabular-nums">{formatCent(l.betragCent)} </span>
)}
{l.beantragtAm && <>Beantragt am {formatDatum(l.beantragtAm)}. </>}
{l.bewilligtAm && <>Bewilligt am {formatDatum(l.bewilligtAm)}. </>}
{l.gueltigBis && <>Gültig bis {formatDatum(l.gueltigBis)}.</>}
</p>
</div>
{darfSchreiben && (
<Button
variant="outline"
size="sm"
onClick={() => setOffen((v) => (v === l.id ? null : l.id))}
>
{offen === l.id ? 'Zuklappen' : 'Bearbeiten'}
</Button>
)}
</div>
{l.katalog && (
<details className="rounded-md border bg-muted/30 p-3">
<summary className="cursor-pointer text-sm font-medium">
Voraussetzungen und benötigte Unterlagen
</summary>
<p className="mt-2 text-sm">{l.katalog.voraussetzung}</p>
{l.katalog.betragHinweis && (
<p className="mt-2 text-sm text-muted-foreground">{l.katalog.betragHinweis}</p>
)}
<p className="mt-2 text-sm font-medium">Dafür brauchst du:</p>
<ul className="mt-1 list-inside list-disc text-sm text-muted-foreground">
{l.katalog.benoetigt.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
</details>
)}
{offen === l.id && darfSchreiben && (
<div className="border-t pt-4">
<LeistungFormular zeile={l} beimSpeichern={() => setOffen(null)} />
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
<Rechtshinweis rechtsgrundlage="SGB XII, SGB V, SGB IX, BayLPflGG" />
</div>
)
}
function LeistungFormular({
zeile,
beimSpeichern,
}: {
zeile: LeistungZeile
beimSpeichern: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await leistungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern()
return ergebnis
},
{},
)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="leistungId" value={zeile.id} />
<Meldung zustand={zustand} />
<Feld id={`bezeichnung-${zeile.id}`} beschriftung="Bezeichnung" pflicht fehler={zustand.felder?.bezeichnung}>
<Input name="bezeichnung" defaultValue={zeile.bezeichnung} required />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id={`traeger-${zeile.id}`} beschriftung="Träger" fehler={zustand.felder?.traeger}>
<Input name="traeger" defaultValue={zeile.traeger} />
</Feld>
<Feld
id={`rechtsgrundlage-${zeile.id}`}
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
>
<Input name="rechtsgrundlage" defaultValue={zeile.rechtsgrundlage} />
</Feld>
<Feld id={`status-${zeile.id}`} beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={zeile.status}>
{LEISTUNG_STATUS.map((s) => (
<option key={s} value={s}>
{LEISTUNG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`betrag-${zeile.id}`}
beschriftung="Betrag"
fehler={zustand.felder?.betrag}
erklaerung="z.B. 1.000,00 für das Landespflegegeld."
>
<Input
name="betrag"
inputMode="decimal"
defaultValue={
zeile.betragCent === null
? ''
: `${Math.trunc(zeile.betragCent / 100)},${String(Math.abs(zeile.betragCent % 100)).padStart(2, '0')}`
}
/>
</Feld>
<Feld
id={`beantragtAm-${zeile.id}`}
beschriftung="Beantragt am"
fehler={zustand.felder?.beantragtAm}
>
<Input name="beantragtAm" type="date" defaultValue={zeile.beantragtAm ?? ''} />
</Feld>
<Feld
id={`bewilligtAm-${zeile.id}`}
beschriftung="Bewilligt am"
fehler={zustand.felder?.bewilligtAm}
>
<Input name="bewilligtAm" type="date" defaultValue={zeile.bewilligtAm ?? ''} />
</Feld>
<Feld
id={`gueltigBis-${zeile.id}`}
beschriftung="Gültig bis"
fehler={zustand.felder?.gueltigBis}
erklaerung="Danach ist ein neuer Nachweis fällig."
>
<Input name="gueltigBis" type="date" defaultValue={zeile.gueltigBis ?? ''} />
</Feld>
</div>
<label className="flex items-start gap-3 text-sm">
<Checkbox
name="jaehrlichNeuBeantragen"
defaultChecked={zeile.jaehrlichNeuBeantragen}
className="mt-0.5"
/>
<span>Muss jährlich neu beantragt werden</span>
</label>
<Feld id={`notizen-${zeile.id}`} beschriftung="Notizen" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={zeile.notizen} rows={3} />
</Feld>
<Absenden>Speichern</Absenden>
</form>
)
}
@@ -0,0 +1,184 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import {
NACHFORDERUNG_BESCHRIFTUNG,
NACHFORDERUNG_ERKLAERUNG,
NACHFORDERUNG_STATUS,
} from '@/lib/sozialhilfe/nachforderungen'
import { nachforderungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type NachforderungWerte = {
id: string | null
bezeichnung: string
erlaeuterung: string
status: NachforderungStatus
angefordertAm: string
fristBis: string
uebersandtAm: string
begruendungNichtVorhanden: string
reihenfolge: number
ausCheckliste: boolean
}
export const LEERE_NACHFORDERUNG: NachforderungWerte = {
id: null,
bezeichnung: '',
erlaeuterung: '',
status: 'OFFEN',
angefordertAm: '',
fristBis: '',
uebersandtAm: '',
begruendungNichtVorhanden: '',
reihenfolge: 0,
ausCheckliste: false,
}
/**
* Eine Zeile des Trackers anlegen oder ändern.
*
* Das Begründungsfeld erscheint, sobald "Nicht vorhanden" gewählt ist.
* Es ist dann Pflicht: Eine unbeantwortete Zeile sieht beim Träger wie
* fehlende Mitwirkung aus (§ 60, § 66 SGB I), eine begründete Fehlanzeige
* dagegen ist eine Antwort.
*/
export function NachforderungFormular({
antragId,
werte,
beimSpeichern,
}: {
antragId: string
werte: NachforderungWerte
beimSpeichern?: () => void
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
async (vorher, formular) => {
const ergebnis = await nachforderungSpeichern(vorher, formular)
if (ergebnis.erfolg) beimSpeichern?.()
return ergebnis
},
{},
)
const [status, setStatus] = useState<NachforderungStatus>(werte.status)
const [fristBis, setFristBis] = useState(werte.fristBis)
return (
<form action={aktion} className="space-y-4">
<input type="hidden" name="antragId" value={antragId} />
{werte.id && <input type="hidden" name="nachforderungId" value={werte.id} />}
<input type="hidden" name="reihenfolge" value={werte.reihenfolge} />
<Meldung zustand={zustand} />
<Feld
id={`bezeichnung-${werte.id ?? 'neu'}`}
beschriftung="Welche Unterlage?"
pflicht
fehler={zustand.felder?.bezeichnung}
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required />
</Feld>
<Feld
id={`erlaeuterung-${werte.id ?? 'neu'}`}
beschriftung="Erläuterung"
fehler={zustand.felder?.erlaeuterung}
erklaerung="Wozu braucht der Träger das? Wer den Zweck notiert, kann Rückfragen vorwegnehmen."
>
<Textarea name="erlaeuterung" defaultValue={werte.erlaeuterung} rows={3} />
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id={`status-${werte.id ?? 'neu'}`}
beschriftung="Status"
fehler={zustand.felder?.status}
erklaerung={NACHFORDERUNG_ERKLAERUNG[status]}
>
<Select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value as NachforderungStatus)}
>
{NACHFORDERUNG_STATUS.map((s) => (
<option key={s} value={s}>
{NACHFORDERUNG_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id={`angefordertAm-${werte.id ?? 'neu'}`}
beschriftung="Angefordert am"
fehler={zustand.felder?.angefordertAm}
erklaerung="Datum des Schreibens vom Träger."
>
<Input name="angefordertAm" type="date" defaultValue={werte.angefordertAm} />
</Feld>
<Feld
id={`fristBis-${werte.id ?? 'neu'}`}
beschriftung="Frist des Trägers"
fehler={zustand.felder?.fristBis}
>
<Input
name="fristBis"
type="date"
value={fristBis}
onChange={(e) => setFristBis(e.target.value)}
/>
</Feld>
<Feld
id={`uebersandtAm-${werte.id ?? 'neu'}`}
beschriftung="Übersandt am"
fehler={zustand.felder?.uebersandtAm}
erklaerung="Der Nachweis der Mitwirkung, falls der Träger später von einer Lücke ausgeht."
>
<Input name="uebersandtAm" type="date" defaultValue={werte.uebersandtAm} />
</Feld>
</div>
{status === 'NICHT_VORHANDEN' && (
<Feld
id={`begruendung-${werte.id ?? 'neu'}`}
beschriftung="Warum gibt es die Unterlage nicht?"
pflicht
fehler={zustand.felder?.begruendungNichtVorhanden}
erklaerung="Dieser Text geht mit ins Unterlagenpaket. Beispiel: „Ein Mietvertrag existiert nicht, die Wohnung wurde 1978 als Eigentum erworben.“"
>
<Textarea
name="begruendungNichtVorhanden"
defaultValue={werte.begruendungNichtVorhanden}
rows={3}
required
/>
</Feld>
)}
{fristBis !== '' && (
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox name="fristAnlegen" className="mt-0.5" />
<span>
Frist im Fristenmodul anlegen.
<span className="mt-0.5 block text-muted-foreground">
Legt eine behördliche Frist zum {fristBis.split('-').reverse().join('.')} an, mit
Vorwarnung 14, 7, 3 und 1 Tag vorher.
</span>
</span>
</label>
)}
<Absenden>{werte.id ? 'Änderungen speichern' : 'Nachforderung aufnehmen'}</Absenden>
</form>
)
}
@@ -0,0 +1,301 @@
'use client'
import { useActionState, useState } from 'react'
import type { NachforderungStatus } from '@prisma/client'
import { CalendarClock, FileText, Pencil, Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Leerzustand } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { AktionMitRueckfrage, Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { NachforderungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import {
LEERE_NACHFORDERUNG,
NachforderungFormular,
type NachforderungWerte,
} from '@/components/sozialhilfe/nachforderung-formular'
import { fristAmpel, trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
import { formatDatum } from '@/lib/utils'
import {
nachforderungLoeschen,
nachforderungStatusSetzen,
type Formularzustand,
} from '@/server/actions/sozialhilfe'
/**
* Der Nachforderungs-Tracker.
*
* Solange auch nur eine Zeile offen ist, ruht das Verfahren. Deshalb steht
* der Balken oben und die überfälligen Zeilen darüber - nicht als
* Statistik, sondern als Arbeitsliste.
*
* Alle Datumswerte kommen als ISO-Zeichenkette aus der Server Component;
* Date-Objekte und BigInt überstehen die Grenze zum Browser nicht.
*/
export type TrackerEintrag = {
id: string
bezeichnung: string
erlaeuterung: string | null
status: NachforderungStatus
angefordertAm: string | null
fristBis: string | null
uebersandtAm: string | null
begruendungNichtVorhanden: string | null
reihenfolge: number
ausCheckliste: boolean
anzahlDokumente: number
}
function alsDatum(iso: string | null): Date | null {
return iso ? new Date(`${iso}T00:00:00.000Z`) : null
}
export function NachforderungTracker({
antragId,
eintraege,
darfSchreiben,
}: {
antragId: string
eintraege: TrackerEintrag[]
darfSchreiben: boolean
}) {
const [neuOffen, setNeuOffen] = useState(false)
const fortschritt = trackerFortschritt(
eintraege.map((e) => ({
id: e.id,
bezeichnung: e.bezeichnung,
status: e.status,
fristBis: alsDatum(e.fristBis),
})),
)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Nachforderungen</CardTitle>
<CardDescription>
Jede vom Träger angeforderte Unterlage als eigene Zeile. Auch eine begründete
Fehlanzeige zählt als geliefert — sie ist eine Antwort.
</CardDescription>
</div>
{darfSchreiben && (
<Button variant="outline" size="sm" onClick={() => setNeuOffen((o) => !o)}>
<Plus aria-hidden="true" />
{neuOffen ? 'Abbrechen' : 'Nachforderung'}
</Button>
)}
</div>
<Fortschritt
className="pt-2"
wert={fortschritt.geliefert}
gesamt={fortschritt.gesamt}
beschriftung={fortschritt.beschriftung}
/>
</CardHeader>
<CardContent className="space-y-4">
{fortschritt.ueberfaellig.length > 0 && (
<Alert variant="warnung">
<CalendarClock aria-hidden="true" />
<AlertTitle>
{fortschritt.ueberfaellig.length}{' '}
{fortschritt.ueberfaellig.length === 1 ? 'Unterlage ist' : 'Unterlagen sind'}{' '}
überfällig
</AlertTitle>
<AlertDescription>
{fortschritt.ueberfaellig.map((z) => z.bezeichnung).join(', ')}. Wenn du sie nicht
beschaffen kannst, melde das dem Träger mit Begründung — Schweigen wird als fehlende
Mitwirkung gewertet (§ 66 SGB I).
</AlertDescription>
</Alert>
)}
{neuOffen && darfSchreiben && (
<div className="rounded-lg border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-semibold">Neue Nachforderung</h3>
<NachforderungFormular
antragId={antragId}
werte={{ ...LEERE_NACHFORDERUNG, reihenfolge: (eintraege.length + 1) * 10 }}
beimSpeichern={() => setNeuOffen(false)}
/>
</div>
)}
{eintraege.length === 0 ? (
<Leerzustand
titel="Noch keine Nachforderungen erfasst"
beschreibung="Übernimm die Standardunterlagen aus der Checkliste — dann steht der übliche Stapel des Bezirks schon hier, bevor er ihn anfordert."
icon={FileText}
/>
) : (
<ul className="divide-y rounded-lg border">
{eintraege.map((eintrag) => (
<TrackerZeile
key={eintrag.id}
antragId={antragId}
eintrag={eintrag}
darfSchreiben={darfSchreiben}
/>
))}
</ul>
)}
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
</CardContent>
</Card>
)
}
function TrackerZeile({
antragId,
eintrag,
darfSchreiben,
}: {
antragId: string
eintrag: TrackerEintrag
darfSchreiben: boolean
}) {
const [offen, setOffen] = useState(false)
const [zustand, statusSetzen] = useActionState<Formularzustand, FormData>(
nachforderungStatusSetzen,
{},
)
const ampel = fristAmpel(alsDatum(eintrag.fristBis), eintrag.status)
return (
<li className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{eintrag.bezeichnung}</span>
<NachforderungAbzeichen status={eintrag.status} />
{eintrag.fristBis && <Badge variant={ampel.variante}>{ampel.text}</Badge>}
{eintrag.ausCheckliste && <Badge variant="outline">aus Checkliste</Badge>}
{eintrag.anzahlDokumente > 0 && (
<Badge variant="outline">
<FileText className="size-3" aria-hidden="true" />
{eintrag.anzahlDokumente}
</Badge>
)}
</div>
{eintrag.erlaeuterung && (
<p className="text-sm text-muted-foreground">{eintrag.erlaeuterung}</p>
)}
<p className="text-xs text-muted-foreground">
{eintrag.angefordertAm && <>Angefordert am {formatDatum(eintrag.angefordertAm)}. </>}
{eintrag.fristBis && <>Frist bis {formatDatum(eintrag.fristBis)}. </>}
{eintrag.uebersandtAm && <>Übersandt am {formatDatum(eintrag.uebersandtAm)}.</>}
</p>
{eintrag.status === 'NICHT_VORHANDEN' && eintrag.begruendungNichtVorhanden && (
<p className="rounded-md border border-border bg-muted/50 p-2 text-sm">
<span className="font-medium">Begründung für den Träger: </span>
{eintrag.begruendungNichtVorhanden}
</p>
)}
</div>
{darfSchreiben && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
{eintrag.status === 'OFFEN' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="IN_BESCHAFFUNG"
text="In Beschaffung"
/>
)}
{(eintrag.status === 'OFFEN' || eintrag.status === 'IN_BESCHAFFUNG') && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="UEBERSANDT"
text="Übersandt"
/>
)}
{eintrag.status === 'UEBERSANDT' && (
<SchnellStatus
aktion={statusSetzen}
nachforderungId={eintrag.id}
ziel="ANERKANNT"
text="Anerkannt"
/>
)}
<Button variant="ghost" size="sm" onClick={() => setOffen((o) => !o)}>
<Pencil aria-hidden="true" />
<span className="sr-only sm:not-sr-only">{offen ? 'Zuklappen' : 'Bearbeiten'}</span>
</Button>
<AktionMitRueckfrage
aktion={nachforderungLoeschen.bind(null, eintrag.id)}
frage={`„${eintrag.bezeichnung}“ in den Papierkorb legen?`}
>
<Trash2 aria-hidden="true" />
<span className="sr-only">Löschen</span>
</AktionMitRueckfrage>
</div>
)}
</div>
<div className="mt-2">
<Meldung zustand={zustand} />
</div>
{offen && darfSchreiben && (
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<NachforderungFormular
antragId={antragId}
werte={{
id: eintrag.id,
bezeichnung: eintrag.bezeichnung,
erlaeuterung: eintrag.erlaeuterung ?? '',
status: eintrag.status,
angefordertAm: eintrag.angefordertAm ?? '',
fristBis: eintrag.fristBis ?? '',
uebersandtAm: eintrag.uebersandtAm ?? '',
begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden ?? '',
reihenfolge: eintrag.reihenfolge,
ausCheckliste: eintrag.ausCheckliste,
}}
beimSpeichern={() => setOffen(false)}
/>
</div>
)}
</li>
)
}
/** Ein-Klick-Statuswechsel für die Fälle, die keine weitere Angabe brauchen. */
function SchnellStatus({
aktion,
nachforderungId,
ziel,
text,
}: {
aktion: (formular: FormData) => void
nachforderungId: string
ziel: NachforderungStatus
text: string
}) {
return (
<form action={aktion}>
<input type="hidden" name="nachforderungId" value={nachforderungId} />
<input type="hidden" name="status" value={ziel} />
<Absenden variant="outline" size="sm" laedtText="…">
{text}
</Absenden>
</form>
)
}
@@ -0,0 +1,173 @@
'use client'
import { useState } from 'react'
import type { SozialhilfeBewertung, VermoegensArt, VertragArt } from '@prisma/client'
import { Coins, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { BewertungAbzeichen } from '@/components/sozialhilfe/abzeichen'
import { BewertungFormular } from '@/components/sozialhilfe/bewertung-formular'
import {
ALLE_BEWERTUNGEN,
BEWERTUNG_BESCHRIFTUNG,
hilfenFuerVermoegensart,
hilfenFuerVertragsart,
} from '@/lib/sozialhilfe/schonvermoegen'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Liste der zu bewertenden Posten mit Filter.
*
* Beträge kommen als Number in Cent herüber - BigInt überlebt die Grenze
* zwischen Server und Browser nicht. Angezeigt wird über formatCent(),
* gerechnet wird auf dem Server.
*/
export type ListenPosten = {
id: string
bezeichnung: string
herkunft: 'VERMOEGEN' | 'VERTRAG'
art: string
artBeschriftung: string
bewertung: SozialhilfeBewertung
wertCent: number | null
wertStichtag: string | null
begruendung: string
zusatz: string | null
}
export function Vermoegensliste({
posten,
darfSchreiben,
}: {
posten: ListenPosten[]
darfSchreiben: boolean
}) {
const [filter, setFilter] = useState<SozialhilfeBewertung | 'ALLE'>('ALLE')
const [offen, setOffen] = useState<string | null>(null)
const sichtbar = filter === 'ALLE' ? posten : posten.filter((p) => p.bewertung === filter)
const zaehler = (b: SozialhilfeBewertung) => posten.filter((p) => p.bewertung === b).length
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Bewertung filtern">
<FilterKnopf aktiv={filter === 'ALLE'} onClick={() => setFilter('ALLE')}>
Alle ({posten.length})
</FilterKnopf>
{ALLE_BEWERTUNGEN.map((b) => (
<FilterKnopf key={b} aktiv={filter === b} onClick={() => setFilter(b)}>
{BEWERTUNG_BESCHRIFTUNG[b]} ({zaehler(b)})
</FilterKnopf>
))}
</div>
{sichtbar.length === 0 ? (
<Leerzustand
titel="Keine Posten in dieser Ansicht"
beschreibung={
posten.length === 0
? 'Vermögensposten und Verträge legst du in den Modulen „Vermögen & Kasse“ und „Verträge“ an. Hier bewertest du sie für das Sozialamt.'
: 'Wähle einen anderen Filter, um die übrigen Posten zu sehen.'
}
icon={Coins}
/>
) : (
<ul className="space-y-3">
{sichtbar.map((p) => (
<li key={`${p.herkunft}-${p.id}`}>
<Card>
<CardContent className="space-y-3 pt-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{p.bezeichnung}</span>
<BewertungAbzeichen bewertung={p.bewertung} />
</div>
<p className="text-sm text-muted-foreground">
{p.artBeschriftung}
{p.zusatz ? ` · ${p.zusatz}` : ''}
</p>
<p className="text-sm">
<span className="font-medium tabular-nums">
{p.wertCent === null ? 'Wert noch nicht ermittelt' : formatCent(p.wertCent)}
</span>
{p.wertStichtag && (
<span className="text-muted-foreground">
{' '}
(Stand {formatDatum(p.wertStichtag)})
</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setOffen((v) => (v === `${p.herkunft}-${p.id}` ? null : `${p.herkunft}-${p.id}`))
}
>
<Pencil aria-hidden="true" />
{offen === `${p.herkunft}-${p.id}` ? 'Zuklappen' : 'Bewerten'}
</Button>
</div>
{p.begruendung && offen !== `${p.herkunft}-${p.id}` && (
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
{p.begruendung}
</p>
)}
{!p.begruendung && p.bewertung !== 'UNGEPRUEFT' && (
<p className="text-sm text-ampel-orange">
Bewertet, aber ohne Begründung. Im Widerspruchsverfahren zählt nur, was
begründet ist.
</p>
)}
{offen === `${p.herkunft}-${p.id}` && (
<div className="border-t pt-4">
<BewertungFormular
herkunft={p.herkunft}
id={p.id}
bezeichnung={p.bezeichnung}
bewertung={p.bewertung}
begruendung={p.begruendung}
darfSchreiben={darfSchreiben}
hilfen={
p.herkunft === 'VERMOEGEN'
? hilfenFuerVermoegensart(p.art as VermoegensArt)
: hilfenFuerVertragsart(p.art as VertragArt)
}
beimSpeichern={() => setOffen(null)}
/>
</div>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
)
}
function FilterKnopf({
aktiv,
onClick,
children,
}: {
aktiv: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button variant={aktiv ? 'default' : 'outline'} size="sm" onClick={onClick} aria-pressed={aktiv}>
{children}
</Button>
)
}
+155
View File
@@ -0,0 +1,155 @@
'use client'
import * as React from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Copy, Loader2, Save } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { Formularzustand } from '@/lib/validierung/substanz'
/**
* Gemeinsame Formularbausteine der Etappe 6 (Immobilie, Verträge, Pflege).
*
* Sie stehen an einer Stelle, damit Absendeknopf, Fehler- und
* Erfolgsmeldung in allen drei Modulen gleich aussehen und sich gleich
* verhalten. Wer täglich zwischen den Seiten wechselt, soll nicht dreimal
* dieselbe Sache neu lernen.
*/
export function Absendeknopf({
children = 'Speichern',
className,
variant,
}: {
children?: React.ReactNode
className?: string
variant?: 'default' | 'outline' | 'secondary' | 'destructive' | 'ghost'
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className={className} variant={variant}>
{pending ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Wird gespeichert …
</>
) : (
<>
<Save aria-hidden="true" />
{children}
</>
)}
</Button>
)
}
/** Zeigt Fehler und Erfolg eines Formularzustands - beides mit Text, nie nur farbig. */
export function Zustandsmeldung({ zustand }: { zustand: Formularzustand }) {
if (zustand.fehler) {
return (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)
}
if (zustand.erfolg) {
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)
}
return null
}
/**
* Ein Textbaustein zum Kopieren.
*
* Bewusst als bearbeitbares Textfeld und nicht als fertiges PDF: Jeder
* Fall hat eine Besonderheit, die in den Brief gehört. Ein Text, den man
* nicht mehr ändern kann, wird früher oder später falsch abgeschickt.
*/
export function Textbaustein({
text,
beschriftung,
hoehe = 'min-h-96',
}: {
text: string
beschriftung: string
hoehe?: string
}) {
const [inhalt, setInhalt] = React.useState(text)
const [kopiert, setKopiert] = React.useState(false)
async function kopieren() {
try {
await navigator.clipboard.writeText(inhalt)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 3000)
} catch {
// Ohne Zwischenablage-Erlaubnis bleibt der Text markierbar - das
// genügt, eine Fehlermeldung wäre hier nur im Weg.
setKopiert(false)
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="textbaustein" className="text-sm font-medium">
{beschriftung}
</label>
<Button type="button" variant="outline" size="sm" onClick={kopieren}>
{kopiert ? (
<>
<CheckCircle2 aria-hidden="true" />
Kopiert
</>
) : (
<>
<Copy aria-hidden="true" />
In die Zwischenablage
</>
)}
</Button>
</div>
<textarea
id="textbaustein"
value={inhalt}
onChange={(e) => setInhalt(e.target.value)}
spellCheck
className={cn(
'w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm leading-relaxed',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
hoehe,
)}
/>
<p className="text-sm text-muted-foreground">
Der Text ist ein Entwurf. Lies ihn durch, ergänze die Besonderheiten deines Falls und
schick ihn erst dann ab. Die PDF-Erzeugung kommt in einer späteren Ausbaustufe.
</p>
</div>
)
}
/** Zwei Werte nebeneinander - die Bauform der Stammdatenblätter. */
export function Datenzeile({
beschriftung,
children,
className,
}: {
beschriftung: string
children: React.ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2 py-2', className)}>
<dt className="text-sm text-muted-foreground">{beschriftung}</dt>
<dd className="text-right font-medium tabular-nums">{children}</dd>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
laufzeitJahre: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
laufzeitJahre: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
>
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="laufzeitJahre"
beschriftung="Laufzeit in Jahren"
erklaerung="Um wie viel verlängert sich der Vertrag? Leer lassen für den Regelfall von einem Jahr."
fehler={zustand.felder?.laufzeitJahre}
>
<Input name="laufzeitJahre" inputMode="numeric" defaultValue={werte.laufzeitJahre} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}