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
344 lines
12 KiB
TypeScript
344 lines
12 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 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>
|
||
)
|
||
}
|