Files
betreuungs-cockpit/src/components/finanzen/buchungs-formular.tsx
T
Florian Wilde fa994350d6 Dauerzahlungen zu einem Vertrag gelten als belegt
Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen
Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert.
Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr
derselbe Satz, mal Anzahl der Vertraege.

Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt
drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine
Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre
Aussage gegenueber dem Rechtspfleger.

Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle,
Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung
nennt den Vertrag samt Nummer statt "ohne Beleg".

Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom
hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird
als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer.

Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den
Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die
Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das
widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des
Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung
waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag
verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht,
und das soll auffallen.

Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage
neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde
nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen
jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb
vergessen werden. Gefunden hat es der Browsertest.

Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung
der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt.

Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass
die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf
beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler.

Refs #11
2026-08-23 09:00:46 +02:00

377 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 VertragAuswahl = {
id: string
bezeichnung: string
gesellschaft: string | null
}
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
vertragId: 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,
vertraege,
buchung,
standardDatum,
schnellerfassung = false,
}: {
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
vertraege: VertragAuswahl[]
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 [vertragId, setVertragId] = React.useState(buchung?.vertragId ?? '')
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 === '' && vertraege.length > 0 && (
<Feld
id="vertragId"
beschriftung="Oder: Dauerzahlung zu einem Vertrag"
erklaerung="Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police. Weicht der Betrag deutlich vom hinterlegten Beitrag ab, wird die Buchung trotzdem zur Prüfung markiert."
fehler={zustand.felder?.vertragId}
>
<Select
name="vertragId"
value={vertragId}
onChange={(ereignis) => setVertragId(ereignis.target.value)}
>
<option value="">– keine Dauerzahlung –</option>
{vertraege.map((vertrag) => (
<option key={vertrag.id} value={vertrag.id}>
{vertrag.bezeichnung}
{vertrag.gesellschaft ? ` (${vertrag.gesellschaft})` : ''}
</option>
))}
</Select>
</Feld>
)}
{belegId === '' && vertragId === '' && (
<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>
)
}