feat: Konten, Kassenbuch, Vermoegen und die beiden Pflicht-PDFs
Etappe 4. Aus diesen Daten entstehen das Vermoegensverzeichnis nach § 1835 BGB und die Rechnungslegung nach §§ 1859, 1865 BGB - beides geht ans Betreuungsgericht, beides ohne dass eine Zahl abgetippt wird. Betreuungsjahr n laeuft von bestellungAm + (n-1) Jahre bis bestellungAm + n Jahre - 1 Tag. Bei Bestellung am 29. Februar endet Jahr 1 am 27.02. und Jahr 2 beginnt am 28.02.: lueckenlos und ohne Ueberschneidung, per Test abgesichert. Rechenprobe der Rechnungslegung: Der Anfangsbestand ist der juengste Saldostand bis zum VORTAG des Zeitraumbeginns - ein Saldostand gilt als Bestand am Tagesende, sonst zaehlte der erste Tag doppelt. Geht die Probe nicht auf, wird die Differenz im PDF ausgewiesen und jeder fehlende Saldostand namentlich benannt. Nie geglaettet: eine still korrigierte Rechnungslegung ist gegenueber dem Gericht wertlos. Posten ohne Wertstand zum Stichtag erscheinen als Luecke, nicht als 0,00 EUR - und Werte aus der Zukunft werden nie herangezogen. Konten kommen ueber ihren Saldostand ins Verzeichnis; Vermoegensposten der Art KONTO werden dort uebersprungen, sonst stuende das Geld doppelt drin. Der CSV- und CAMT-Import raet KEINE Kategorien, sondern bucht alles auf "Sonstige". Eine falsch geratene Kategorie faellt in der Jahresuebersicht niemandem auf und verfaelscht den Bericht. Fremdwaehrung wird abgelehnt statt umgerechnet. Der XML-Leser weist DOCTYPE ab (XXE). 68 Tests, darunter echte IBAN-Pruefziffern und die Rundung: es darf nirgends ein Cent verschwinden. Etappe: 4 Phase: Geld
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
# Schema-Wünsche aus Etappe 4 „Geld“
|
||||||
|
|
||||||
|
Beim Bau des Finanzmoduls sind Stellen aufgefallen, an denen das
|
||||||
|
Datenmodell etwas anders aussehen müsste. `prisma/schema.prisma` wurde
|
||||||
|
**nicht** angefasst. Sortiert nach Dringlichkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Umbuchungen kennen ihr Gegenkonto nicht
|
||||||
|
|
||||||
|
**Modell:** `Buchung`
|
||||||
|
|
||||||
|
Eine Umbuchung zwischen zwei eigenen Konten – Giro aufs Sparbuch – lässt
|
||||||
|
sich derzeit nur als zwei getrennte Buchungen abbilden. Im Modell fehlt
|
||||||
|
die Angabe, wohin das Geld gegangen ist. Für die Rechenprobe ist das
|
||||||
|
folgenlos (Umbuchungen sind weder Einnahme noch Ausgabe und werden
|
||||||
|
herausgerechnet), für den Nutzer aber unbequem: Er muss zweimal
|
||||||
|
erfassen, und niemand prüft, ob beide Seiten zusammenpassen.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Buchung {
|
||||||
|
gegenkontoId String?
|
||||||
|
gegenkonto Konto? @relation("Umbuchungsgegenkonto", fields: [gegenkontoId], references: [id], onDelete: SetNull)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit ließe sich eine Umbuchung als **eine** Zeile erfassen und beim
|
||||||
|
Bilden der Kontostände auf beiden Seiten berücksichtigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dublettenschutz greift nicht bei Buchungen ohne Konto
|
||||||
|
|
||||||
|
**Modell:** `Buchung`, `@@unique([kontoId, importReferenz])`
|
||||||
|
|
||||||
|
In PostgreSQL ist `NULL` nie gleich `NULL`. Die zusammengesetzte
|
||||||
|
Eindeutigkeit schützt deshalb nur, solange `kontoId` gesetzt ist. Bei
|
||||||
|
einem Import ohne Konto – derzeit über die Oberfläche nicht möglich,
|
||||||
|
aber im Modell erlaubt – könnte derselbe Umsatz beliebig oft angelegt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Zwei mögliche Auswege:
|
||||||
|
|
||||||
|
- `kontoId` bei Buchungen mit `importReferenz` verpflichtend machen
|
||||||
|
(Prüfung in der Anwendung, Constraint im Schema nicht ausdrückbar), oder
|
||||||
|
- eine partielle Eindeutigkeit über Roh-SQL ergänzen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX buchung_import_ohne_konto
|
||||||
|
ON buchung (betreute_id, import_referenz)
|
||||||
|
WHERE konto_id IS NULL AND import_referenz IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung erzwingt derzeit ein Konto beim Import; der Schutz hängt
|
||||||
|
also allein an der Oberfläche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Saldostand und Wertstand haben keinen Papierkorb
|
||||||
|
|
||||||
|
**Modelle:** `Saldostand`, `Wertstand`
|
||||||
|
|
||||||
|
Beide Modelle haben kein `geloeschtAm`. Löschen ist damit endgültig –
|
||||||
|
im Gegensatz zu allem anderen im System. Das ist an dieser Stelle
|
||||||
|
besonders unangenehm: Wird ein Wertstand entfernt, lässt sich eine
|
||||||
|
bereits beim Gericht eingereichte Vermögensübersicht nicht mehr
|
||||||
|
nachbilden. Die Oberfläche warnt deshalb ausdrücklich davor, aber ein
|
||||||
|
Papierkorb wäre die bessere Absicherung.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Saldostand {
|
||||||
|
geloeschtAm DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Wertstand {
|
||||||
|
geloeschtAm DateTime?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nebenwirkung, die dabei zu bedenken ist: die bestehenden
|
||||||
|
`@@unique([kontoId, stichtag])` bzw. `@@unique([vermoegenspostenId, stichtag])`
|
||||||
|
würden dann auch gelöschte Einträge blockieren. Sie müssten auf
|
||||||
|
partielle Indizes mit `WHERE geloescht_am IS NULL` umgestellt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wer hat den Stand erfasst?
|
||||||
|
|
||||||
|
**Modelle:** `Saldostand`, `Wertstand`
|
||||||
|
|
||||||
|
Beide tragen nur `erstelltAm`, keinen Verweis auf den Benutzer. Bei
|
||||||
|
mehreren Betreuern oder Mitlesern mit Schreibrecht steht im Datensatz
|
||||||
|
selbst nicht, wer die Zahl eingetragen hat. Das Audit-Log beantwortet
|
||||||
|
die Frage, aber nur über eine gesonderte Recherche.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
erstelltVonId String?
|
||||||
|
erstelltVon User? @relation(fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Indizes für die Auswertungen
|
||||||
|
|
||||||
|
**Modelle:** `Buchung`, `Saldostand`
|
||||||
|
|
||||||
|
Die Berichte lesen Buchungen fast immer entweder nach Konto und Zeitraum
|
||||||
|
oder gefiltert auf fehlende Belege. Dafür gibt es bisher keine passenden
|
||||||
|
Indizes:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Buchung {
|
||||||
|
@@index([kontoId, datum])
|
||||||
|
@@index([betreuteId, belegId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei den erwarteten Datenmengen (einige tausend Buchungen je betreuter
|
||||||
|
Person) ist das noch nicht spürbar; bei einem Import mehrerer Jahre
|
||||||
|
Kontoauszüge wird es das.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Fremdwährung wird abgelehnt, nicht abgebildet
|
||||||
|
|
||||||
|
**Modelle:** `Konto`, `Buchung`, `Saldostand`, `Wertstand`
|
||||||
|
|
||||||
|
Es gibt kein Währungsfeld. Alle Beträge sind Euro-Cent. Der CSV- und
|
||||||
|
CAMT-Import überspringt deshalb Zeilen in fremder Währung und meldet das
|
||||||
|
dem Nutzer, statt umzurechnen – ein umgerechneter Betrag im
|
||||||
|
Kassenbuch wäre in der Rechnungslegung eine Behauptung ohne Beleg.
|
||||||
|
|
||||||
|
Das ist als bewusste Entscheidung dokumentiert und **kein** Wunsch nach
|
||||||
|
einem Währungsfeld. Es steht hier nur, damit die Einschränkung nicht
|
||||||
|
später für ein Versehen gehalten wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Der Betrag einer Buchung sollte positiv sein
|
||||||
|
|
||||||
|
**Modell:** `Buchung.betragCent`
|
||||||
|
|
||||||
|
Fachlich ist der Betrag immer positiv; die Richtung steht in
|
||||||
|
`richtung`. Ein negativer Betrag bei `richtung = AUSGABE` würde die
|
||||||
|
Vorzeichen zweimal drehen und die Summen still verfälschen. Die
|
||||||
|
Anwendung prüft das beim Speichern (`betragFeld({ nurPositiv: true })`),
|
||||||
|
in der Datenbank ist es nicht abgesichert. Ein Check-Constraint über
|
||||||
|
Roh-SQL wäre die Ergänzung:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE buchung ADD CONSTRAINT buchung_betrag_positiv CHECK (betrag_cent > 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Vor einer solchen Migration müssten Altdaten geprüft werden.
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { AlertCircle, FileDown } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
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 { Feld } from '@/components/feld'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Hinweisliste, Summenzeile } from '@/components/finanzen/bausteine'
|
||||||
|
import { centZuZahl } from '@/lib/db'
|
||||||
|
import { formatCent, formatDatum } from '@/lib/utils'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
import { heute, tagAlsText } from '@/lib/finanzen/datum'
|
||||||
|
import {
|
||||||
|
aufwandspauschaleBis,
|
||||||
|
betreuungsjahre,
|
||||||
|
jahrAbgeschlossen,
|
||||||
|
} from '@/lib/finanzen/betreuungsjahr'
|
||||||
|
import { standardVariante, VARIANTE_BESCHRIFTUNG } from '@/lib/finanzen/rechnungslegung'
|
||||||
|
import { rechnungslegungDaten } from '@/lib/berichte/gemeinsam'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Berichte' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugung der beiden Berichte ans Betreuungsgericht.
|
||||||
|
*
|
||||||
|
* Bevor eine PDF entsteht, steht hier die Rechenprobe des zuletzt
|
||||||
|
* abgeschlossenen Betreuungsjahres. Der Gedanke: Eine Differenz will man
|
||||||
|
* sehen, bevor man das Dokument unterschreibt – nicht danach.
|
||||||
|
*/
|
||||||
|
export default async function BerichteSeite() {
|
||||||
|
const { betreute } = await finanzKontext()
|
||||||
|
const stichtagHeute = heute()
|
||||||
|
|
||||||
|
const jahre = betreute.bestellungAm ? betreuungsjahre(betreute.bestellungAm, stichtagHeute) : []
|
||||||
|
const abgeschlossene = jahre.filter((jahr) => jahrAbgeschlossen(jahr, stichtagHeute))
|
||||||
|
const letztesAbgeschlossen = abgeschlossene[abgeschlossene.length - 1] ?? null
|
||||||
|
const vorauswahl = letztesAbgeschlossen ?? jahre[jahre.length - 1] ?? null
|
||||||
|
const variante = standardVariante(betreute.befreiterBetreuer)
|
||||||
|
|
||||||
|
// Vorschau der Rechenprobe - nur, wenn es ein Jahr dafuer gibt.
|
||||||
|
const vorschau = vorauswahl
|
||||||
|
? await rechnungslegungDaten({
|
||||||
|
betreute,
|
||||||
|
jahrNummer: vorauswahl.nummer,
|
||||||
|
variante,
|
||||||
|
erstelltVon: '(Vorschau)',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Berichte"
|
||||||
|
beschreibung="Vermögensverzeichnis und Rechnungslegung – beide entstehen unmittelbar aus den erfassten Konten, Buchungen und Wertständen. Es wird nichts abgetippt."
|
||||||
|
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Berichte' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!betreute.bestellungAm && (
|
||||||
|
<Alert variant="warnung">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>Das Bestellungsdatum fehlt</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Ohne
|
||||||
|
dieses Datum lässt sich die Rechnungslegung nicht abgrenzen. Trag es in den Stammdaten
|
||||||
|
nach – das Vermögensverzeichnis kannst du auch so erzeugen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vermögensverzeichnis (§ 1835 BGB)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Binnen drei Monaten nach der Bestellung beim Betreuungsgericht einzureichen. Zeigt
|
||||||
|
Aktiva nach Gruppen, Passiva, Reinvermögen sowie die laufenden Einnahmen und Ausgaben.
|
||||||
|
Jeder Wert trägt seine Quelle und den Tag seiner Bewertung.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="get" action="/finanzen/berichte/pdf" className="space-y-4">
|
||||||
|
<input type="hidden" name="art" value="vermoegensverzeichnis" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
id="stichtag"
|
||||||
|
beschriftung="Stichtag"
|
||||||
|
pflicht
|
||||||
|
erklaerung="Es wird je Posten der jüngste Wertstand verwendet, der nicht nach diesem Tag liegt."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="stichtag"
|
||||||
|
defaultValue={tagAlsText(betreute.bestellungAm ?? stichtagHeute)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="monate"
|
||||||
|
beschriftung="Rückblick für laufende Einnahmen und Ausgaben"
|
||||||
|
erklaerung="Aus wie vielen Monaten Kassenbuch soll der Monatsdurchschnitt gebildet werden?"
|
||||||
|
>
|
||||||
|
<Select name="monate" defaultValue="12">
|
||||||
|
<option value="3">3 Monate</option>
|
||||||
|
<option value="6">6 Monate</option>
|
||||||
|
<option value="12">12 Monate</option>
|
||||||
|
<option value="24">24 Monate</option>
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Vermögensverzeichnis erzeugen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vermögensübersicht und Rechnungslegung (§§ 1859, 1865 BGB)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{betreute.befreiterBetreuer
|
||||||
|
? 'Als befreiter Betreuer schuldest du nach § 1859 BGB nur die knappe Vermögensübersicht. Die vollständige Rechnungslegung kannst du trotzdem erzeugen – etwa, wenn das Gericht sie ausdrücklich verlangt.'
|
||||||
|
: 'Es ist die vollständige Rechnungslegung nach § 1865 BGB geschuldet: jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{jahre.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sobald das Bestellungsdatum in den Stammdaten steht, kannst du hier je
|
||||||
|
Betreuungsjahr abrechnen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form method="get" action="/finanzen/berichte/pdf" className="space-y-4">
|
||||||
|
<input type="hidden" name="art" value="rechnungslegung" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
id="jahr"
|
||||||
|
beschriftung="Betreuungsjahr"
|
||||||
|
pflicht
|
||||||
|
erklaerung="Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr."
|
||||||
|
>
|
||||||
|
<Select name="jahr" defaultValue={String(vorauswahl?.nummer ?? 1)}>
|
||||||
|
{jahre.map((jahr) => (
|
||||||
|
<option key={jahr.nummer} value={jahr.nummer}>
|
||||||
|
{jahr.bezeichnung}
|
||||||
|
{jahrAbgeschlossen(jahr, stichtagHeute) ? '' : ' – läuft noch'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
id="variante"
|
||||||
|
beschriftung="Variante"
|
||||||
|
pflicht
|
||||||
|
erklaerung="Vorbelegt nach dem Eintrag „befreiter Betreuer“ in den Stammdaten – hier jederzeit umschaltbar."
|
||||||
|
>
|
||||||
|
<Select name="variante" defaultValue={variante}>
|
||||||
|
<option value="UEBERSICHT_1859">
|
||||||
|
{VARIANTE_BESCHRIFTUNG.UEBERSICHT_1859}
|
||||||
|
</option>
|
||||||
|
<option value="VOLL_1865">{VARIANTE_BESCHRIFTUNG.VOLL_1865}</option>
|
||||||
|
</Select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<FileDown aria-hidden="true" />
|
||||||
|
Bericht erzeugen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vorauswahl && !jahrAbgeschlossen(vorauswahl, stichtagHeute) && (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>Noch keine Schlussrechnung möglich</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Das {vorauswahl.nummer}. Betreuungsjahr läuft noch bis zum{' '}
|
||||||
|
{formatDatum(vorauswahl.bis)}. Der Bericht wird als Zwischenaufstellung
|
||||||
|
gekennzeichnet.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{letztesAbgeschlossen && (
|
||||||
|
<Alert variant="hinweis">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>Aufwandspauschale nicht vergessen</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Für das {letztesAbgeschlossen.nummer}. Betreuungsjahr (Ende{' '}
|
||||||
|
{formatDatum(letztesAbgeschlossen.bis)}) kann die Aufwandspauschale nach § 1878
|
||||||
|
BGB bis zum {formatDatum(aufwandspauschaleBis(letztesAbgeschlossen))} beantragt
|
||||||
|
werden. Das ist eine Ausschlussfrist – danach ist der Anspruch weg.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------------------- */}
|
||||||
|
{vorschau && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rechenprobe – {vorschau.jahr.bezeichnung}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben muss den Endbestand ergeben.
|
||||||
|
Stimmt es nicht, steht die Differenz auch im PDF – sie wird nicht stillschweigend
|
||||||
|
glattgezogen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung={`Anfangsbestand am ${formatDatum(vorschau.rechnung.von)}`}
|
||||||
|
cent={centZuZahl(vorschau.rechnung.probe.anfangsbestandCent) ?? 0}
|
||||||
|
/>
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung="zuzüglich Einnahmen"
|
||||||
|
cent={centZuZahl(vorschau.rechnung.probe.einnahmenCent) ?? 0}
|
||||||
|
/>
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung="abzüglich Ausgaben"
|
||||||
|
cent={-(centZuZahl(vorschau.rechnung.probe.ausgabenCent) ?? 0)}
|
||||||
|
/>
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung="rechnerischer Endbestand"
|
||||||
|
cent={centZuZahl(vorschau.rechnung.probe.rechnerischerEndbestandCent) ?? 0}
|
||||||
|
betont
|
||||||
|
/>
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung={`Endbestand laut Saldoständen am ${formatDatum(vorschau.rechnung.bis)}`}
|
||||||
|
cent={centZuZahl(vorschau.rechnung.probe.gebuchterEndbestandCent) ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorschau.rechnung.probe.stimmt ? (
|
||||||
|
<Alert variant="erfolg">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>Die Rechnung geht auf</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Bestände und Buchungen passen auf den Cent zusammen.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="fehler">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>
|
||||||
|
Differenz:{' '}
|
||||||
|
{formatCent(centZuZahl(vorschau.rechnung.probe.differenzCent))}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Entweder fehlen Buchungen im Kassenbuch, oder ein Saldostand ist nicht erfasst.
|
||||||
|
Am schnellsten findest du die Lücke über{' '}
|
||||||
|
<Link href="/finanzen/konten" className="underline">
|
||||||
|
die Kontostände
|
||||||
|
</Link>{' '}
|
||||||
|
und die{' '}
|
||||||
|
<Link
|
||||||
|
href={`/finanzen/buchungen?von=${tagAlsText(vorschau.rechnung.von)}&bis=${tagAlsText(vorschau.rechnung.bis)}`}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Buchungen dieses Zeitraums
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Hinweisliste hinweise={vorschau.rechnung.hinweise} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 1859 BGB, § 1865 BGB, § 1878 BGB" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
import { tagAusText } from '@/lib/finanzen/datum'
|
||||||
|
import { betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
|
||||||
|
import { standardVariante, type Variante } from '@/lib/finanzen/rechnungslegung'
|
||||||
|
import {
|
||||||
|
berichtVermerken,
|
||||||
|
rechnungslegungDaten,
|
||||||
|
vermoegensverzeichnisDaten,
|
||||||
|
} from '@/lib/berichte/gemeinsam'
|
||||||
|
import {
|
||||||
|
vermoegensverzeichnisDateiname,
|
||||||
|
vermoegensverzeichnisPdf,
|
||||||
|
} from '@/lib/berichte/vermoegensverzeichnis'
|
||||||
|
import { rechnungslegungDateiname, rechnungslegungPdf } from '@/lib/berichte/rechnungslegung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auslieferung der beiden Finanzberichte als PDF.
|
||||||
|
*
|
||||||
|
* Die Datei wird bei jedem Abruf frisch aus den Daten erzeugt - es gibt
|
||||||
|
* keinen zwischengespeicherten Stand, der von der Datenbank abweichen
|
||||||
|
* koennte. Dass ein Bericht erzeugt wurde, wird in der Tabelle Bericht
|
||||||
|
* und im Audit-Log festgehalten: Gegenueber dem Gericht muss ich sagen
|
||||||
|
* koennen, was ich wann in welcher Fassung abgegeben habe.
|
||||||
|
*
|
||||||
|
* Ausgeliefert wird immer als Anhang und mit nosniff - ein Dokument aus
|
||||||
|
* dieser Anwendung wird nie im Browser als HTML ausgefuehrt.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: NextRequest): Promise<Response> {
|
||||||
|
const { betreute, benutzer } = await finanzKontext()
|
||||||
|
const parameter = anfrage.nextUrl.searchParams
|
||||||
|
const art = parameter.get('art')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (art === 'vermoegensverzeichnis') {
|
||||||
|
const stichtag = tagAusText(parameter.get('stichtag'))
|
||||||
|
if (!stichtag) {
|
||||||
|
return fehlerantwort('Es fehlt ein gültiger Stichtag im Format JJJJ-MM-TT.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const monateRoh = Number.parseInt(parameter.get('monate') ?? '12', 10)
|
||||||
|
const monateRueckblick =
|
||||||
|
Number.isInteger(monateRoh) && monateRoh >= 1 && monateRoh <= 60 ? monateRoh : 12
|
||||||
|
|
||||||
|
const daten = await vermoegensverzeichnisDaten({
|
||||||
|
betreute,
|
||||||
|
stichtag,
|
||||||
|
erstelltVon: benutzer.name,
|
||||||
|
monateRueckblick,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pdf = await vermoegensverzeichnisPdf(daten)
|
||||||
|
const dateiname = vermoegensverzeichnisDateiname(daten)
|
||||||
|
|
||||||
|
await berichtVermerken({
|
||||||
|
betreuteId: betreute.id,
|
||||||
|
benutzer,
|
||||||
|
art: 'VERMOEGENSVERZEICHNIS',
|
||||||
|
titel: `Vermögensverzeichnis zum ${dateiname.slice(0, 10)}`,
|
||||||
|
stichtag,
|
||||||
|
dateiname,
|
||||||
|
})
|
||||||
|
|
||||||
|
return pdfAntwort(pdf, dateiname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (art === 'rechnungslegung') {
|
||||||
|
if (!betreute.bestellungAm) {
|
||||||
|
return fehlerantwort(
|
||||||
|
'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. Bitte zuerst in den ' +
|
||||||
|
'Stammdaten das Datum der Bestellung eintragen.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jahrNummer = Number.parseInt(parameter.get('jahr') ?? '', 10)
|
||||||
|
if (!Number.isInteger(jahrNummer) || jahrNummer < 1) {
|
||||||
|
return fehlerantwort('Es fehlt die Nummer des Betreuungsjahres.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const gewaehlt = parameter.get('variante')
|
||||||
|
const variante: Variante =
|
||||||
|
gewaehlt === 'UEBERSICHT_1859' || gewaehlt === 'VOLL_1865'
|
||||||
|
? gewaehlt
|
||||||
|
: standardVariante(betreute.befreiterBetreuer)
|
||||||
|
|
||||||
|
const daten = await rechnungslegungDaten({
|
||||||
|
betreute,
|
||||||
|
jahrNummer,
|
||||||
|
variante,
|
||||||
|
erstelltVon: benutzer.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pdf = await rechnungslegungPdf(daten)
|
||||||
|
const dateiname = rechnungslegungDateiname(daten)
|
||||||
|
const jahr = betreuungsjahr(betreute.bestellungAm, jahrNummer)
|
||||||
|
|
||||||
|
await berichtVermerken({
|
||||||
|
betreuteId: betreute.id,
|
||||||
|
benutzer,
|
||||||
|
art: variante === 'UEBERSICHT_1859' ? 'VERMOEGENSUEBERSICHT' : 'RECHNUNGSLEGUNG',
|
||||||
|
titel: daten.kopf.titel,
|
||||||
|
zeitraumVon: jahr.von,
|
||||||
|
zeitraumBis: jahr.bis,
|
||||||
|
dateiname,
|
||||||
|
})
|
||||||
|
|
||||||
|
return pdfAntwort(pdf, dateiname)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fehlerantwort(
|
||||||
|
'Unbekannte Berichtsart. Erwartet wird „vermoegensverzeichnis“ oder „rechnungslegung“.',
|
||||||
|
)
|
||||||
|
} catch (ausnahme) {
|
||||||
|
console.error('[finanzen] Bericht konnte nicht erzeugt werden:', ausnahme)
|
||||||
|
return fehlerantwort(
|
||||||
|
ausnahme instanceof Error
|
||||||
|
? ausnahme.message
|
||||||
|
: 'Der Bericht konnte nicht erzeugt werden.',
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfAntwort(pdf: Buffer, dateiname: string): Response {
|
||||||
|
// Der Dateiname wird zweimal angegeben: einmal auf ASCII reduziert fuer
|
||||||
|
// aeltere Browser, einmal UTF-8-kodiert nach RFC 5987 fuer die uebrigen.
|
||||||
|
const ascii = dateiname.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(pdf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Length': String(pdf.byteLength),
|
||||||
|
'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(dateiname)}`,
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Cache-Control': 'no-store, max-age=0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehlerantwort(meldung: string, status = 400): Response {
|
||||||
|
return new NextResponse(meldung, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Cache-Control': 'no-store, max-age=0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Buchungsformular } from '@/components/finanzen/buchungs-formular'
|
||||||
|
import { Loeschknopf } from '@/components/finanzen/aktionen'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
|
import { formatDatum, formatDatumZeit } from '@/lib/utils'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
import { tagAlsText } from '@/lib/finanzen/datum'
|
||||||
|
import { betragFormatieren } from '@/lib/finanzen/betrag'
|
||||||
|
import { buchungLoeschen } from '@/server/actions/finanzen'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Buchung' }
|
||||||
|
|
||||||
|
export default async function BuchungSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const { betreute, darfSchreiben } = await finanzKontext()
|
||||||
|
const schreiben = darfSchreiben('FINANZEN')
|
||||||
|
|
||||||
|
const buchung = await db.buchung.findFirst({
|
||||||
|
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
})
|
||||||
|
if (!buchung) notFound()
|
||||||
|
|
||||||
|
const offen = oeffnen('Buchung', buchung)
|
||||||
|
|
||||||
|
const [konten, belege] = await Promise.all([
|
||||||
|
db.konto.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
|
||||||
|
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
|
||||||
|
}),
|
||||||
|
db.dokument.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
orderBy: { erstelltAm: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel={buchung.gegenstelle ?? 'Buchung'}
|
||||||
|
beschreibung={`Gebucht am ${formatDatum(buchung.datum)}, erfasst am ${formatDatumZeit(buchung.erstelltAm)}.`}
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||||
|
{ titel: 'Kassenbuch', href: '/finanzen/buchungen' },
|
||||||
|
{ titel: formatDatum(buchung.datum) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{buchung.importReferenz && (
|
||||||
|
<p className="rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||||
|
Diese Buchung stammt aus einem Dateiimport. Der Herkunftsschlüssel verhindert, dass
|
||||||
|
derselbe Umsatz beim nächsten Import ein zweites Mal angelegt wird.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schreiben ? (
|
||||||
|
<Buchungsformular
|
||||||
|
betreuteId={betreute.id}
|
||||||
|
konten={konten}
|
||||||
|
belege={belege}
|
||||||
|
buchung={{
|
||||||
|
id: buchung.id,
|
||||||
|
kontoId: buchung.kontoId,
|
||||||
|
datum: tagAlsText(buchung.datum),
|
||||||
|
wertstellung: tagAlsText(buchung.wertstellung) || null,
|
||||||
|
betrag: betragFormatieren(buchung.betragCent),
|
||||||
|
richtung: buchung.richtung,
|
||||||
|
kategorie: buchung.kategorie,
|
||||||
|
gegenstelle: buchung.gegenstelle,
|
||||||
|
verwendungszweck: offen.verwendungszweck,
|
||||||
|
belegId: buchung.belegId,
|
||||||
|
belegFehltBegruendung: buchung.belegFehltBegruendung,
|
||||||
|
betrifftBetreute: buchung.betrifftBetreute,
|
||||||
|
vermoegenswirksam: buchung.vermoegenswirksam,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nur lesender Zugriff</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
Du darfst dieses Modul lesen, aber nicht ändern.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schreiben && (
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<Loeschknopf
|
||||||
|
aktion={buchungLoeschen}
|
||||||
|
felder={{ betreuteId: betreute.id, buchungId: buchung.id }}
|
||||||
|
beschriftung="Buchung in den Papierkorb"
|
||||||
|
variante="outline"
|
||||||
|
frage="Buchung in den Papierkorb legen? Sie bleibt 30 Tage wiederherstellbar. Achtung: Danach geht die Rechenprobe der Rechnungslegung um diesen Betrag nicht mehr auf, solange der Saldostand unverändert bleibt."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Importformular } from '@/components/finanzen/import-formular'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Kontoumsätze einlesen' }
|
||||||
|
|
||||||
|
export default async function ImportSeite() {
|
||||||
|
const { betreute, darfSchreiben } = await finanzKontext()
|
||||||
|
if (!darfSchreiben('FINANZEN')) notFound()
|
||||||
|
|
||||||
|
const konten = await db.konto.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
|
||||||
|
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Kontoumsätze einlesen"
|
||||||
|
beschreibung="Abtippen ist die Stelle, an der in der Rechnungslegung die Fehler entstehen. Ein Zahlendreher pro hundert Zeilen reicht, und die Rechenprobe geht nicht mehr auf."
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||||
|
{ titel: 'Kassenbuch', href: '/finanzen/buchungen' },
|
||||||
|
{ titel: 'Einlesen' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{konten.length === 0 ? (
|
||||||
|
<Alert variant="warnung">
|
||||||
|
<AlertCircle aria-hidden="true" />
|
||||||
|
<AlertTitle>Es gibt noch kein Konto</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Umsätze werden immer einem Konto zugeordnet.{' '}
|
||||||
|
<Link href="/finanzen/konten/neu" className="underline">
|
||||||
|
Erst ein Konto anlegen
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Importformular betreuteId={betreute.id} konten={konten} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Was erkannt wird</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Nichts wird stillschweigend geraten. Was nicht gelesen werden kann, wird dir Zeile für
|
||||||
|
Zeile gezeigt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Trennzeichen:</strong> Semikolon, Komma,
|
||||||
|
Tabulator oder senkrechter Strich – erkannt an der Spaltenzahl, nicht am Raten.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Zeichensatz:</strong> UTF-8 und
|
||||||
|
Windows-1252/ISO-8859-1. Umlaute bleiben Umlaute.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Beträge:</strong> „1.234,56“, „1234,56“, mit
|
||||||
|
nachgestelltem Minus („1.234,56-“), in Klammern und in englischer Schreibweise.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Datum:</strong> TT.MM.JJJJ, TT.MM.JJ,
|
||||||
|
JJJJ-MM-TT und TT/MM/JJJJ.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Spalten:</strong> Buchungstag, Wertstellung,
|
||||||
|
Betrag oder getrennte Soll-/Haben-Spalten, Soll/Haben-Kennzeichen, Begünstigter,
|
||||||
|
Verwendungszweck, Währung.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Vorspann:</strong> Zeilen vor der eigentlichen
|
||||||
|
Tabelle werden übersprungen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">CAMT.053:</strong> der elektronische
|
||||||
|
Kontoauszug als XML wird ebenfalls gelesen – dort sind Betrag und Richtung
|
||||||
|
eindeutig ausgezeichnet, das ist der genauere Weg.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Fremdwährung</strong> wird nicht übernommen und
|
||||||
|
nicht umgerechnet. Diese Anwendung rechnet in Euro.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" kurz />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Loeschknopf } from '@/components/finanzen/aktionen'
|
||||||
|
import { Postenformular, Wertstandformular } from '@/components/finanzen/posten-formulare'
|
||||||
|
import { Wertverlauf, type Verlaufszeile } from '@/components/finanzen/wertverlauf'
|
||||||
|
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
|
import { formatDatum } from '@/lib/utils'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
import { heute, tagAlsText } from '@/lib/finanzen/datum'
|
||||||
|
import { wertverlauf } from '@/lib/finanzen/vermoegen'
|
||||||
|
import {
|
||||||
|
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
|
||||||
|
VERMOEGENS_ART_EINZAHL,
|
||||||
|
} from '@/lib/finanzen/kategorien'
|
||||||
|
import { vermoegenspostenLoeschen, wertstandLoeschen } from '@/server/actions/finanzen'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Vermögensposten' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Vermögensposten mit seiner vollständigen Wert-Historie.
|
||||||
|
*
|
||||||
|
* Werte werden hier nur hinzugefügt, nie geändert. Das ist der Kern der
|
||||||
|
* Nachvollziehbarkeit: Wenn das Gericht in zwei Jahren fragt, warum im
|
||||||
|
* Verzeichnis 195.000 € standen, muss dieselbe Zahl mit derselben Quelle
|
||||||
|
* wieder herauskommen.
|
||||||
|
*/
|
||||||
|
export default async function VermoegenspostenSeite({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { betreute, darfSchreiben } = await finanzKontext()
|
||||||
|
const schreiben = darfSchreiben('FINANZEN')
|
||||||
|
|
||||||
|
const posten = await db.vermoegensposten.findFirst({
|
||||||
|
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
include: {
|
||||||
|
wertstaende: {
|
||||||
|
orderBy: { stichtag: 'asc' },
|
||||||
|
include: { nachweisDokument: { select: { id: true, titel: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!posten) notFound()
|
||||||
|
|
||||||
|
const offen = oeffnen('Vermoegensposten', posten)
|
||||||
|
|
||||||
|
const [konten, dokumente] = await Promise.all([
|
||||||
|
db.konto.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
orderBy: { bezeichnung: 'asc' },
|
||||||
|
}),
|
||||||
|
db.dokument.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
orderBy: { erstelltAm: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const verlauf = wertverlauf(
|
||||||
|
posten.wertstaende.map((wert) => ({
|
||||||
|
stichtag: wert.stichtag,
|
||||||
|
wertCent: wert.wertCent,
|
||||||
|
wertquelle: wert.wertquelle,
|
||||||
|
nachweisDokumentId: wert.nachweisDokumentId,
|
||||||
|
nachweisTitel: wert.nachweisDokument?.titel ?? null,
|
||||||
|
bemerkung: wert.bemerkung,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// wertverlauf() sortiert aufsteigend nach Stichtag - genau wie die
|
||||||
|
// Abfrage oben. Die Indizes passen deshalb zueinander.
|
||||||
|
const zeilen: Verlaufszeile[] = verlauf.map((punkt, i) => {
|
||||||
|
const roh = posten.wertstaende[i]!
|
||||||
|
return {
|
||||||
|
id: roh.id,
|
||||||
|
stichtag: tagAlsText(punkt.stichtag),
|
||||||
|
cent: centZuZahl(punkt.wertCent) ?? 0,
|
||||||
|
wertquelle: punkt.wertquelle,
|
||||||
|
nachweisTitel: roh.nachweisDokument?.titel ?? null,
|
||||||
|
bemerkung: roh.bemerkung,
|
||||||
|
veraenderungCent: centZuZahl(punkt.veraenderungCent),
|
||||||
|
aktion: schreiben ? (
|
||||||
|
<Loeschknopf
|
||||||
|
aktion={wertstandLoeschen}
|
||||||
|
felder={{ betreuteId: betreute.id, wertstandId: roh.id }}
|
||||||
|
frage={`Wert vom ${formatDatum(roh.stichtag)} endgültig entfernen? Er wandert nicht in den Papierkorb – eine damit erstellte Vermögensübersicht lässt sich danach nicht mehr nachbilden.`}
|
||||||
|
/>
|
||||||
|
) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SeitenKopf
|
||||||
|
titel={posten.bezeichnung}
|
||||||
|
beschreibung={
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{VERMOEGENS_ART_EINZAHL[posten.art]}</Badge>
|
||||||
|
<Badge variant={posten.bewertungSozialhilfe === 'GESCHUETZT' ? 'gruen' : 'secondary'}>
|
||||||
|
Sozialhilfe: {SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[posten.bewertungSozialhilfe]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">
|
||||||
|
{posten.wertstaende.length} Wertstand/Wertstände erfasst
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||||
|
{ titel: 'Vermögen', href: '/finanzen/vermoegen' },
|
||||||
|
{ titel: posten.bezeichnung },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{offen.bewertungBegruendung && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Einordnung im Sozialhilfeverfahren</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Deine eigene Dokumentation und Argumentation gegenüber dem Bezirk – keine
|
||||||
|
Rechtsauskunft.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="whitespace-pre-line text-sm">
|
||||||
|
{offen.bewertungBegruendung}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Wertverlauf</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Jeder Eintrag ist eine Aussage über den Wert an einem bestimmten Tag, mit Angabe der
|
||||||
|
Quelle. Nichts wird überschrieben.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{zeilen.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
Noch kein Wert erfasst. Solange kein Wertstand da ist, taucht dieser Posten im
|
||||||
|
Vermögensverzeichnis als Lücke auf – nicht mit 0,00 €.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Wertverlauf zeilen={zeilen} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schreiben && (
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="mb-3 text-base font-semibold">Neuen Wert festhalten</h3>
|
||||||
|
<Wertstandformular
|
||||||
|
betreuteId={betreute.id}
|
||||||
|
vermoegenspostenId={posten.id}
|
||||||
|
dokumente={dokumente}
|
||||||
|
standardStichtag={tagAlsText(heute())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{offen.notizen && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notizen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="whitespace-pre-line text-sm">{offen.notizen}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schreiben && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Posten ändern</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<Postenformular
|
||||||
|
betreuteId={betreute.id}
|
||||||
|
konten={konten}
|
||||||
|
posten={{
|
||||||
|
id: posten.id,
|
||||||
|
bezeichnung: posten.bezeichnung,
|
||||||
|
art: posten.art,
|
||||||
|
kontoId: posten.kontoId,
|
||||||
|
bewertungSozialhilfe: posten.bewertungSozialhilfe,
|
||||||
|
bewertungBegruendung: offen.bewertungBegruendung,
|
||||||
|
notizen: offen.notizen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<Loeschknopf
|
||||||
|
aktion={vermoegenspostenLoeschen}
|
||||||
|
felder={{ betreuteId: betreute.id, postenId: posten.id }}
|
||||||
|
beschriftung="Posten in den Papierkorb"
|
||||||
|
variante="outline"
|
||||||
|
frage="Posten in den Papierkorb legen? Er bleibt 30 Tage wiederherstellbar; die Wertstände wandern mit."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 90 SGB XII" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Postenformular } from '@/components/finanzen/posten-formulare'
|
||||||
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Vermögensposten anlegen' }
|
||||||
|
|
||||||
|
export default async function VermoegenNeuSeite() {
|
||||||
|
const { betreute, darfSchreiben } = await finanzKontext()
|
||||||
|
if (!darfSchreiben('FINANZEN')) notFound()
|
||||||
|
|
||||||
|
const konten = await db.konto.findMany({
|
||||||
|
where: { betreuteId: betreute.id, ...nichtGeloescht },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
orderBy: { bezeichnung: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Vermögensposten anlegen"
|
||||||
|
beschreibung="Erst die Sache benennen, dann bewerten. Den Wert trägst du gleich danach als Wertstand mit Stichtag und Quelle ein – so bleibt jede Vermögensübersicht später nachvollziehbar."
|
||||||
|
pfad={[
|
||||||
|
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||||
|
{ titel: 'Vermögen', href: '/finanzen/vermoegen' },
|
||||||
|
{ titel: 'Neu' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Postenformular betreuteId={betreute.id} konten={konten} />
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 90 SGB XII" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Boxes, Plus } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
|
||||||
|
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||||
|
import { Betrag, Hinweisliste, Kennzahl, Summenzeile } from '@/components/finanzen/bausteine'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { centZuZahl } from '@/lib/db'
|
||||||
|
import { formatCent, formatDatum } from '@/lib/utils'
|
||||||
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
||||||
|
import { heute, tagAlsText, tagAusText } from '@/lib/finanzen/datum'
|
||||||
|
import {
|
||||||
|
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
|
||||||
|
VERMOEGENS_ART_BESCHRIFTUNG,
|
||||||
|
VERMOEGENS_ART_EINZAHL,
|
||||||
|
} from '@/lib/finanzen/kategorien'
|
||||||
|
import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
|
||||||
|
import type { SozialhilfeBewertung } from '@prisma/client'
|
||||||
|
|
||||||
|
export const metadata = { title: 'Vermögen' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vermögensposten mit ihrem Wert an einem frei wählbaren Stichtag.
|
||||||
|
*
|
||||||
|
* Der Stichtag ist hier kein Beiwerk: Genau so entsteht später das
|
||||||
|
* Vermögensverzeichnis. Wer den Stichtag zurücksetzt, sieht das
|
||||||
|
* Vermögen so, wie es damals im Verzeichnis stand – und nicht so, wie
|
||||||
|
* man es heute gerne hätte.
|
||||||
|
*/
|
||||||
|
export default async function VermoegenSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
|
}) {
|
||||||
|
const parameter = await searchParams
|
||||||
|
const { betreute, darfSchreiben } = await finanzKontext()
|
||||||
|
const schreiben = darfSchreiben('FINANZEN')
|
||||||
|
|
||||||
|
const gewaehlt =
|
||||||
|
typeof parameter.stichtag === 'string' ? tagAusText(parameter.stichtag) : null
|
||||||
|
const stichtag = gewaehlt ?? heute()
|
||||||
|
|
||||||
|
const bild = await vermoegenLaden(betreute.id, stichtag)
|
||||||
|
|
||||||
|
const hinweise = bild.ohneWert.map(
|
||||||
|
(posten) =>
|
||||||
|
`„${posten.bezeichnung}“ hat zum ${formatDatum(stichtag)} keinen Wert. ` +
|
||||||
|
(posten.spaetereWertstaende > 0
|
||||||
|
? 'Es gibt nur Wertstände nach diesem Stichtag – die dürfen hier nicht verwendet werden.'
|
||||||
|
: 'Es ist noch kein Wertstand erfasst.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SeitenKopf
|
||||||
|
titel="Vermögen"
|
||||||
|
beschreibung="Grundbesitz, Versicherungen, Fahrzeuge, Forderungen und Verbindlichkeiten. Jeder Posten wird über Wertstände bewertet – nie über ein einzelnes Feld, das überschrieben wird."
|
||||||
|
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Vermögen' }]}
|
||||||
|
aktionen={
|
||||||
|
schreiben ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/finanzen/vermoegen/neu">
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Posten anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form method="get" className="flex flex-wrap items-end gap-3 rounded-lg border p-4">
|
||||||
|
<div className="min-w-[14rem]">
|
||||||
|
<label htmlFor="stichtag" className="mb-1.5 block text-sm font-medium">
|
||||||
|
Vermögen zum Stichtag
|
||||||
|
</label>
|
||||||
|
<Input id="stichtag" type="date" name="stichtag" defaultValue={tagAlsText(stichtag)} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Anzeigen
|
||||||
|
</Button>
|
||||||
|
<p className="w-full text-sm text-muted-foreground">
|
||||||
|
Es wird je Posten der jüngste Wertstand verwendet, der nicht nach dem Stichtag liegt.
|
||||||
|
Spätere Bewertungen bleiben außen vor – auch dann, wenn sie genauer wären.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Kennzahl titel="Aktiva" wert={formatCent(centZuZahl(bild.summeAktivaCent))} />
|
||||||
|
<Kennzahl titel="Verbindlichkeiten" wert={formatCent(centZuZahl(bild.summePassivaCent))} />
|
||||||
|
<Kennzahl
|
||||||
|
titel="Reinvermögen"
|
||||||
|
wert={formatCent(centZuZahl(bild.reinvermoegenCent))}
|
||||||
|
hinweis="ohne Kontoguthaben – die stehen unter „Konten“"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Hinweisliste hinweise={hinweise} titel="Diese Posten fehlen im Verzeichnis" />
|
||||||
|
|
||||||
|
{bild.aktivaGruppen.length === 0 && bild.passiva.length === 0 && bild.ohneWert.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={Boxes}
|
||||||
|
titel="Noch kein Vermögensposten erfasst"
|
||||||
|
beschreibung="Das Hausgrundstück und die Versicherungen gehören hierher – sie machen im Vermögensverzeichnis den größten Teil aus."
|
||||||
|
aktion={
|
||||||
|
schreiben ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/finanzen/vermoegen/neu">
|
||||||
|
<Plus aria-hidden="true" />
|
||||||
|
Posten anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{bild.aktivaGruppen.map((gruppe) => (
|
||||||
|
<Card key={gruppe.art}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Postentabelle posten={gruppe.posten} />
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung={`Summe ${VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}`}
|
||||||
|
cent={centZuZahl(gruppe.summeCent) ?? 0}
|
||||||
|
betont
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{bild.passiva.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Verbindlichkeiten</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Werden mit positivem Betrag geführt; dass sie das Vermögen mindern, ergibt sich
|
||||||
|
aus der Art des Postens.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Postentabelle posten={bild.passiva} />
|
||||||
|
<Summenzeile
|
||||||
|
beschriftung="Summe Verbindlichkeiten"
|
||||||
|
cent={centZuZahl(bild.summePassivaCent) ?? 0}
|
||||||
|
betont
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bild.ohneWert.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ohne Bewertung zum Stichtag</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Diese Posten sind vorhanden, gehen aber in keine Summe ein.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{bild.ohneWert.map((posten) => (
|
||||||
|
<li key={posten.id} className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/finanzen/vermoegen/${posten.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{posten.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="gelb">{VERMOEGENS_ART_EINZAHL[posten.art]}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{posten.spaetereWertstaende > 0
|
||||||
|
? `${posten.spaetereWertstaende} Wertstand/Wertstände, alle nach dem Stichtag`
|
||||||
|
: 'noch kein Wertstand'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 90 SGB XII" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Postentabelle({
|
||||||
|
posten,
|
||||||
|
}: {
|
||||||
|
posten: {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string
|
||||||
|
wertCent: bigint
|
||||||
|
bewertetAm: Date
|
||||||
|
wertquelle: string
|
||||||
|
alterTage: number
|
||||||
|
nachweisTitel: string | null
|
||||||
|
bewertungSozialhilfe: string | null
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Posten</TableHead>
|
||||||
|
<TableHead>Wertquelle</TableHead>
|
||||||
|
<TableHead>Bewertet am</TableHead>
|
||||||
|
<TableHead>Sozialhilfe</TableHead>
|
||||||
|
<TableHead className="text-right">Wert</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{posten.map((eintrag) => (
|
||||||
|
<TableRow key={eintrag.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/finanzen/vermoegen/${eintrag.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{eintrag.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<span className="block">{eintrag.wertquelle}</span>
|
||||||
|
{eintrag.nachweisTitel && (
|
||||||
|
<span className="block">Beleg: {eintrag.nachweisTitel}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||||
|
{formatDatum(eintrag.bewertetAm)}
|
||||||
|
{eintrag.alterTage > 365 && (
|
||||||
|
<Badge variant="gelb" className="ml-2">
|
||||||
|
über ein Jahr alt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{eintrag.bewertungSozialhilfe
|
||||||
|
? SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[
|
||||||
|
eintrag.bewertungSozialhilfe as SozialhilfeBewertung
|
||||||
|
]
|
||||||
|
: '–'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Betrag cent={centZuZahl(eintrag.wertCent)} fett />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'server-only'
|
import 'server-only'
|
||||||
import type { Betreute, User } from '@prisma/client'
|
import type { Betreute, User } from '@prisma/client'
|
||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
|
import { protokollieren } from '@/lib/audit'
|
||||||
import { oeffnen } from '@/lib/crypto/feld'
|
import { oeffnen } from '@/lib/crypto/feld'
|
||||||
import type { Berichtskopf } from '@/lib/pdf/rahmen'
|
import type { Berichtskopf } from '@/lib/pdf/rahmen'
|
||||||
import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
|
import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
|
||||||
@@ -405,9 +406,51 @@ export async function rechnungslegungDaten(eingabe: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Name, der unter dem Bericht steht. */
|
/**
|
||||||
export function erstellerName(benutzer: User): string {
|
* Haelt fest, dass ein Bericht erzeugt wurde.
|
||||||
return benutzer.name
|
*
|
||||||
|
* Die PDF-Datei selbst wird bei jedem Abruf frisch erzeugt; hier steht
|
||||||
|
* nur, was wann in welcher Fassung erstellt und damit potenziell
|
||||||
|
* abgegeben wurde. Gegenueber dem Betreuungsgericht und moeglichen
|
||||||
|
* Miterben muss ich das belegen koennen.
|
||||||
|
*
|
||||||
|
* Bewusst keine Server Action: Aufgerufen wird das nur serverseitig aus
|
||||||
|
* der PDF-Route. Als Action waere es ein zusaetzlicher oeffentlicher
|
||||||
|
* Endpunkt, ueber den sich Berichtseintraege erzeugen liessen.
|
||||||
|
*/
|
||||||
|
export async function berichtVermerken(eingabe: {
|
||||||
|
betreuteId: string
|
||||||
|
benutzer: User
|
||||||
|
art: 'VERMOEGENSVERZEICHNIS' | 'VERMOEGENSUEBERSICHT' | 'RECHNUNGSLEGUNG'
|
||||||
|
titel: string
|
||||||
|
stichtag?: Date | null
|
||||||
|
zeitraumVon?: Date | null
|
||||||
|
zeitraumBis?: Date | null
|
||||||
|
dateiname: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const bericht = await db.bericht.create({
|
||||||
|
data: {
|
||||||
|
betreuteId: eingabe.betreuteId,
|
||||||
|
art: eingabe.art,
|
||||||
|
titel: eingabe.titel,
|
||||||
|
stichtag: eingabe.stichtag ?? null,
|
||||||
|
zeitraumVon: eingabe.zeitraumVon ?? null,
|
||||||
|
zeitraumBis: eingabe.zeitraumBis ?? null,
|
||||||
|
dateiname: eingabe.dateiname,
|
||||||
|
erstelltVonId: eingabe.benutzer.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
aktion: 'EXPORTIERT',
|
||||||
|
entitaet: 'Bericht',
|
||||||
|
entitaetId: bericht.id,
|
||||||
|
bezeichnung: eingabe.titel,
|
||||||
|
betreuteId: eingabe.betreuteId,
|
||||||
|
userId: eingabe.benutzer.id,
|
||||||
|
userEmail: eingabe.benutzer.email,
|
||||||
|
userName: eingabe.benutzer.name,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function kurz(datum: Date): string {
|
function kurz(datum: Date): string {
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import { Prisma } from '@prisma/client'
|
|||||||
import { db, nichtGeloescht } from '@/lib/db'
|
import { db, nichtGeloescht } from '@/lib/db'
|
||||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||||
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
|
||||||
import { oeffnen, sealen } from '@/lib/crypto/feld'
|
import { sealen } from '@/lib/crypto/feld'
|
||||||
import { blindindex } from '@/lib/crypto/hash'
|
import { blindindex } from '@/lib/crypto/hash'
|
||||||
import { alsTag } from '@/lib/finanzen/datum'
|
import { alsTag } from '@/lib/finanzen/datum'
|
||||||
import { ibanMaskieren } from '@/lib/finanzen/iban'
|
|
||||||
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
|
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
|
||||||
import { camtZuBuchungen } from '@/lib/finanzen/camt'
|
import { camtZuBuchungen } from '@/lib/finanzen/camt'
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +44,6 @@ export type Formularzustand = {
|
|||||||
meldung?: string[]
|
meldung?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEER: Formularzustand = {}
|
|
||||||
|
|
||||||
function zugriffsfehler(ausnahme: unknown): Formularzustand | null {
|
function zugriffsfehler(ausnahme: unknown): Formularzustand | null {
|
||||||
if (ausnahme instanceof ZugriffVerweigert) {
|
if (ausnahme instanceof ZugriffVerweigert) {
|
||||||
return { fehler: ausnahme.message }
|
return { fehler: ausnahme.message }
|
||||||
@@ -824,67 +821,3 @@ export async function wertstandLoeschen(
|
|||||||
revalidatePath('/finanzen/vermoegen')
|
revalidatePath('/finanzen/vermoegen')
|
||||||
return { erfolg: 'Wert gelöscht.' }
|
return { erfolg: 'Wert gelöscht.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Berichte
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haelt fest, dass ein Bericht erzeugt wurde. Die PDF-Datei selbst wird
|
|
||||||
* beim Abruf erzeugt und ausgeliefert; hier steht nur, was wann in
|
|
||||||
* welcher Fassung abgegeben wurde.
|
|
||||||
*/
|
|
||||||
export async function berichtVermerken(eingabe: {
|
|
||||||
betreuteId: string
|
|
||||||
art: 'VERMOEGENSVERZEICHNIS' | 'VERMOEGENSUEBERSICHT' | 'RECHNUNGSLEGUNG'
|
|
||||||
titel: string
|
|
||||||
stichtag?: Date | null
|
|
||||||
zeitraumVon?: Date | null
|
|
||||||
zeitraumBis?: Date | null
|
|
||||||
dateiname: string
|
|
||||||
}): Promise<void> {
|
|
||||||
const { benutzer } = await erfordereZugang(eingabe.betreuteId, 'FINANZEN', 'lesen')
|
|
||||||
|
|
||||||
const bericht = await db.bericht.create({
|
|
||||||
data: {
|
|
||||||
betreuteId: eingabe.betreuteId,
|
|
||||||
art: eingabe.art,
|
|
||||||
titel: eingabe.titel,
|
|
||||||
stichtag: eingabe.stichtag ?? null,
|
|
||||||
zeitraumVon: eingabe.zeitraumVon ?? null,
|
|
||||||
zeitraumBis: eingabe.zeitraumBis ?? null,
|
|
||||||
dateiname: eingabe.dateiname,
|
|
||||||
erstelltVonId: benutzer.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await protokollieren({
|
|
||||||
aktion: 'EXPORTIERT',
|
|
||||||
entitaet: 'Bericht',
|
|
||||||
entitaetId: bericht.id,
|
|
||||||
bezeichnung: eingabe.titel,
|
|
||||||
betreuteId: eingabe.betreuteId,
|
|
||||||
userId: benutzer.id,
|
|
||||||
userEmail: benutzer.email,
|
|
||||||
userName: benutzer.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kleine Hilfe fuer Formulare, die nur den Anfangszustand brauchen. */
|
|
||||||
export async function leererZustand(): Promise<Formularzustand> {
|
|
||||||
return LEER
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zeigt die IBAN eines Kontos maskiert - fuer Listen ohne Klartext. */
|
|
||||||
export async function kontoIbanMaskiert(
|
|
||||||
betreuteId: string,
|
|
||||||
kontoId: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
await erfordereZugang(betreuteId, 'FINANZEN', 'lesen')
|
|
||||||
const konto = await db.konto.findFirst({
|
|
||||||
where: { id: kontoId, betreuteId, ...nichtGeloescht },
|
|
||||||
})
|
|
||||||
if (!konto) return null
|
|
||||||
const offen = oeffnen('Konto', konto)
|
|
||||||
return offen.iban ? ibanMaskieren(offen.iban) : null
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user