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
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
'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>
|
||
)
|
||
}
|