docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user