Compare commits

..
4 Commits
Author SHA1 Message Date
florian.wilde a52523fc97 Merge #15: Schreiben erzeugen, ausgeben und ablegen
Behebt Issue #4: Ein Schreiben geht jetzt den ganzen Weg - bearbeiten,
Word-Datei, Ablage als Ausgangsdokument, Wiedervorlage. Auch an Stellen,
zu denen es noch keinen Datensatz gibt.

Closes #4
2026-08-23 08:18:38 +00:00
Florian Wilde c21d03abf6 Schreiben erzeugen, ausgeben und ablegen
Die Anschreiben gab es bisher nur als Text zum Kopieren. Danach war
Handarbeit: in Word nachbauen, ausdrucken, abschicken - und noch einmal
von Hand als Dokument in die Akte legen. Der letzte Schritt wurde am
haeufigsten vergessen, und dann fehlt der Nachweis im Bericht.

Neu unter /schreiben/neu: bearbeiten, als Word-Datei herunterladen, in
die Akte legen, Wiedervorlage setzen - in einem Zug. Eine Seite fuer alle
Arten und alle drei Empfaengerwege, statt die Bearbeitung dreimal an
Vertrag, Vorgang und Freitext nachzubauen.

Der dritte Weg ist der eigentliche Zugewinn: Schreiben an eine Stelle,
zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung ist das
der Normalfall - man fragt ja an, um zu erfahren, ob dort ueberhaupt
etwas besteht. Aus der freien Anschrift laesst sich mit einem Klick ein
Kontakt anlegen.

Zwei Entscheidungen, die erklaert gehoeren:

Die Akte bekommt ein PDF, heruntergeladen wird .docx. Der
Objektspeicher nimmt nur PDF und Bilder an, geprueft an den Magic Bytes.
Eine .docx ist ein ZIP-Archiv; sie durchzulassen hiesse jedes ZIP
durchzulassen, und diese Pruefung ist eine bewusste Zusage der
Anwendung. Sachlich ist PDF fuer den Nachweis ohnehin richtiger: Es
aendert sich nicht mehr. Die Oberflaeche sagt dazu, dass eine spaeter in
Word geaenderte Fassung von der Akte abweicht.

Der Berichtssatz in berichte/docx.ts bleibt unangetastet. Er setzt einen
Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief braucht
Absender, Anschriftenfeld, Datumszeile und Grussformel. Beides in eine
Funktion zu zwingen haette beide Ausgaben schlechter gemacht. Der
Umsetzungshinweis im Issue ("muss nur angebunden werden") war insoweit
falsch - von mir selbst geschrieben.

Die vorhandenen Erzeuger (Rueckkaufswert, Kuendigung, Sachstandsanfrage)
bleiben unveraendert; ihr Wortlaut ist getestet und erprobt.

Geprueft: zehn Einheitstests, darunter der Gleichschritt der
Wiedervorlagefristen ueber zwei Module hinweg. Fuenf End-to-End-Tests
auf beiden Geraeteprofilen: Entwurf, Vertragsbezug, Word-Download,
Ablage samt Wiedervorlage, Kontakt aus freier Anschrift.

Refs #4
2026-08-23 10:14:59 +02:00
florian.wilde de21f1c320 Merge #14: Dauerzahlungen gelten als belegt
Behebt Issue #11: Bei einem Dauerschuldverhaeltnis ist der Vertrag der
Beleg - fuer zwoelf gleiche Lastschriften gibt es keine zwoelf
Quittungen, sondern eine Police.

Closes #11
2026-08-23 07:06:26 +00:00
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
41 changed files with 3277 additions and 478 deletions
+155 -1
View File
@@ -26,6 +26,158 @@ Wiederherstellung*.
_Noch nichts._
## [1.4.0] – 2026-08-23
### Hinzugefügt
- **Schreiben erzeugen, ausgeben und ablegen**
([#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4)).
Die Anschreiben an Versicherer und Behörden gab es bisher nur als Text
zum Kopieren. Danach war Handarbeit: in Word nachbauen, ausdrucken,
abschicken — und noch einmal von Hand als Dokument in die Akte legen.
Vier Schritte, und der letzte wurde am häufigsten vergessen. Dann fehlt
der Nachweis genau dort, wo er gebraucht wird.
Neu unter **Schreiben**: Text bearbeiten, als **Word-Datei**
herunterladen, **in die Akte legen** und dabei eine **Wiedervorlage**
setzen — in einem Zug.
Der Text bleibt vollständig bearbeitbar. Jeder Fall hat eine
Besonderheit, die in den Brief gehört; ein Formulartext, den man nicht
mehr ändern kann, wird früher oder später falsch abgeschickt.
- **Schreiben ohne Vertrag und ohne Vorgang.** Bisher ging ein Anschreiben
nur zu einem vorhandenen Datensatz. Am Anfang einer Betreuung schreibt
man aber gerade an Stellen, zu denen es noch keinen gibt — man fragt ja
an, um zu erfahren, ob dort überhaupt etwas besteht. Jetzt geht es an
einen **Kontakt** oder an eine **frei getippte Anschrift**, aus der sich
mit einem Klick ein Kontakt anlegen lässt. So wächst das Adressbuch
nebenbei beim Schreiben statt durch Pflege.
- **Die Wiedervorlage entsteht mit.** Rückkaufswert 28 Tage, Kündigung 21,
Sachstandsanfrage 42 — jeweils änderbar, und daneben steht, warum die
Frist so lang ist. Bei einer Sachstandsanfrage wird zusätzlich der letzte
Kontakt am Vorgang nachgezogen, damit *Wartet auf andere* auf „Heute"
stimmt.
### Zwei Entscheidungen, die erklärt gehören
**In die Akte kommt eine PDF-Fassung, heruntergeladen wird eine
Word-Datei.** Der Objektspeicher nimmt nur PDF und Bilder an, geprüft an
den Magic Bytes — eine Word-Datei ist ein ZIP-Archiv, sie durchzulassen
hieße jedes ZIP durchzulassen. Diese Prüfung ist eine bewusste Zusage der
Anwendung; sie für eine Bequemlichkeit aufzuweichen wäre der falsche
Handel. Und sachlich ist PDF für den Nachweis ohnehin das richtige Format:
Es ändert sich nicht mehr.
Daraus folgt eine Einschränkung, die auch in der Oberfläche steht:
**Änderst du die Word-Datei danach noch**, weicht die Akte davon ab. Dann
lieber dort fertig schreiben, den Text zurückkopieren und erst dann
ablegen.
**Der Berichtssatz wurde nicht umgebogen.** Die vorhandene DOCX-Ausgabe
setzt einen Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief
braucht Absender, Anschriftenfeld, Datumszeile und Grußformel. Beides in
eine Funktion zu zwingen hätte beide Ausgaben schlechter gemacht.
### Geprüft
- Zehn Einheitstests, darunter der Gleichschritt der Wiedervorlagefristen:
Sie stehen an zwei Stellen und dürfen nicht auseinanderlaufen, sonst
mahnt die Anwendung zu zwei verschiedenen Terminen.
- Fünf End-to-End-Tests: Entwurf mit Bestellungsabsatz, Vertragsbezug,
Word-Download, Ablage samt Wiedervorlage und Kontakt aus freier
Anschrift.
Zwei Fehler hat erst der Browsertest gefunden — beide in den Tests
selbst: Die Dokumentenliste rendert Tabelle **und** Karten, je nach
Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
unsichtbare Fassung sieht aus wie ein Fehler in der Anwendung.
## [1.3.0] – 2026-08-23
### Hinzugefügt
- **Dauerzahlungen zu einem Vertrag gelten als belegt**
([#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11)).
Bei einer monatlichen Versicherungsprämie gibt es keinen Beleg — es gibt
einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich
quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwölfmal im
Jahr derselbe Satz, mal Anzahl der Verträge.
Eine Buchung lässt sich jetzt einem **Vertrag** zuordnen, und diese
Zuordnung zählt als Nachweis. Bei einem Dauerschuldverhältnis ist der
Vertrag der Beleg: Ein Rechtspfleger will für zwölf gleiche Lastschriften
keine zwölf Quittungen sehen, sondern die Police.
**Die Belegquote weist drei Fälle getrennt aus** — mit Beleg, durch
Vertrag belegt, begründet ohne Beleg. Zusammengeworfen verlöre die Zahl
ihre Aussage.
- **Automatisch beim Import**, wenn die Gegenstelle zu genau *einem*
Vertrag passt. Bei Mehrdeutigkeit geschieht nichts: Hausrat und
Haftpflicht bei derselben Gesellschaft lassen sich nicht auseinanderhalten,
und eine falsche Zuordnung wäre schlimmer als gar keine — sie behauptet
einen Beleg, und niemand sieht noch einmal hin. Automatisch gesetzte
Zuordnungen sind in der Liste als solche gekennzeichnet.
- **Sammelzuordnung** unter *Wiederkehrende Zahlungen*: Liegt zu einer
Gruppe ein Vertrag vor, werden alle ihre Zahlungen mit einem Klick
zugeordnet. Bei drei Jahren Laufzeit sind das 36 Buchungen.
- **Die Rechnungslegung nennt den Vertrag.** Statt „ohne Beleg – …" steht
dort „Dauerzahlung zu Vertrag «Bezeichnung», Nr. …". Die Lücke wird nicht
versteckt, sondern erklärt: Wer nachprüfen will, weiß sofort, welches
Papier er aus dem Ordner holt.
### Die Sicherung dagegen
**Weicht der Betrag um mehr als ein Fünftel vom hinterlegten Beitrag ab,
gilt die Zahlung nicht als belegt**, sondern wird als *Betrag prüfen*
markiert. Sonst wäre die Zuordnung eine Hintertür, durch die auch eine
verzehnfachte Prämie unbemerkt durchginge.
Ein Fünftel ist bewusst großzügig: Beitragsanpassungen und
Versicherungsteuer bewegen sich in dieser Größenordnung, und dabei soll
die Anwendung nicht meckern. Wer die Grenze enger zieht, bekommt täglich
Warnungen und sieht die eine wichtige nicht mehr.
Steht am Vertrag **kein** Beitrag, findet keine Prüfung statt. Die
Zuordnung gilt trotzdem — es wird nur nicht so getan, als wäre geprüft
worden.
Und: **Verschwindet der Vertrag, fällt die Buchung zurück in „Beleg
fehlt".** Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre
schlimmer als gar keiner.
### Zu beachten
- **Eine Datenbank-Migration ist nötig**
(`20260823062000_buchung_vertragszahlung`). Sie fügt zwei Spalten hinzu
und ändert nichts an vorhandenen Daten.
- Bestehende Buchungen sind nicht betroffen: Ohne Zuordnung bleibt alles,
wie es war.
### Geprüft
- 23 neue Einheitstests. Die Hälfte prüft, dass die Anerkennung auch
wirklich **wegfällt** — der gefährliche Fehler wäre hier ein zu mildes
Urteil, nicht ein zu strenges.
- Fünf End-to-End-Tests: Sammelzuordnung, Anzeige in der Buchungsliste,
die abweichende Zahlung, die Kachel auf „Heute", und der Rückfall nach
dem Löschen des Vertrags.
Der Browsertest hat zwei Fehler gefunden, die alle anderen Prüfungen
durchgelassen haben — siehe unten.
### Behoben
- **Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage** neben
der gemeinsamen aus `berichte/gemeinsam.ts`. Als die Dauerzahlungen
dazukamen, wurde nur eine von beiden nachgezogen: In der Liste stand
weiter „Beleg fehlt", obwohl der Vertrag hinterlegt war. Beide benutzen
jetzt dieselbe Abbildung — die kann nicht halb vergessen werden.
## [1.2.0] – 2026-08-23
### Hinzugefügt
@@ -616,7 +768,9 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...HEAD
[1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0
[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0
[1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2
[1.1.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...v1.1.1
+4 -4
View File
@@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind.
| | | nachzuzählen mit |
|---|---:|---|
| Zeilen TypeScript/TSX | 74.119 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Zeilen TypeScript/TSX | 76.610 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
| Seiten | 79 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 537 | `npm test` |
| End-to-End-Tests (Playwright) | 53 | `npm run test:e2e` — 30 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
| Seiten | 81 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 570 | `npm test` |
| End-to-End-Tests (Playwright) | 73 | `npm run test:e2e` — 40 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
| TypeScript-Fehler | 0 | `npm run typecheck` |
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
+7 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.2.0
git checkout v1.4.0
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.2.0
git describe --tags # v1.4.0
```
---
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.2.0","commit":"..."}
# {"status":"bereit","version":"1.4.0","commit":"..."}
```
Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash
curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.2.0",...}
# {"status":"bereit","version":"1.4.0",...}
```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.2.0 · <commit>`.
- Unten links in der Seitenleiste steht `Version 1.4.0 · <commit>`.
---
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung |
|---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.2.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh v1.4.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main
# 3. Umschalten
git checkout v1.2.0
git checkout v1.4.0
# 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.1.2** ([CHANGELOG](../CHANGELOG.md))
Stand: 23.08.2026 · Aktuelle Fassung: **1.4.0** ([CHANGELOG](../CHANGELOG.md))
---
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Dauerzahlungen zu einem Vertrag — gegen die echte Anwendung.
*
* Die Einheitstests nageln die Belegzustände fest. Hier geht es um das, was
* sie nicht sehen: Kommt die Zuordnung in der Datenbank an, verschwindet die
* Buchung wirklich aus „Beleg fehlt", und trägt die abweichende Zahlung
* sichtbar die Warnung?
*
* Der Test legt Vertrag und Buchungen selbst an und räumt sie weg.
*/
const GESELLSCHAFT = 'Prüfhausen Rentenversicherung'
const VERTRAG = 'Prüfhausen Rente (Test)'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.buchung.deleteMany({ where: { gegenstelle: GESELLSCHAFT } })
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
}
/**
* Sechs passende Monatsraten plus eine, die zehnmal so hoch ist.
* Die letzte ist der eigentliche Prüffall.
*/
async function testdatenAnlegen(): Promise<void> {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
const vertrag = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: VERTRAG,
gesellschaft: GESELLSCHAFT,
art: 'RENTENVERSICHERUNG_PRIVAT',
beitragCent: 4250n,
rhythmus: 'MONATLICH',
},
select: { id: true },
})
const konto = await db.konto.findFirst({
where: { betreuteId: person.id, geloeschtAm: null },
select: { id: true },
})
const gemeinsam = {
betreuteId: person.id,
kontoId: konto?.id ?? null,
richtung: 'AUSGABE' as const,
kategorie: 'VERSICHERUNG' as const,
betrifftBetreute: true,
gegenstelle: GESELLSCHAFT,
}
for (let monat = 1; monat <= 6; monat++) {
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, monat - 1, 1)),
betragCent: 4250n,
},
})
}
// Die Ausreißerin: schon zugeordnet, aber zehnmal so hoch.
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, 6, 1)),
betragCent: 42_500n,
vertragId: vertrag.id,
},
})
}
})
}
test.describe('Dauerzahlungen', () => {
test.beforeAll(async () => {
await aufraeumen()
await testdatenAnlegen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('ordnet alle Zahlungen einer Gruppe in einem Schritt zu', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
// Der Vertrag liegt vor, also steht die Gruppe unter „schon zugeordnet".
const eintrag = page.locator('li', { hasText: VERTRAG }).first()
await expect(eintrag).toBeVisible()
// Sechs offene Zahlungen — die siebte hängt schon am Vertrag.
const knopf = eintrag.getByRole('button', { name: /6 Zahlungen als belegt zuordnen/ })
await expect(knopf).toBeVisible()
await knopf.click()
// Die Rückmeldung ist der neue Zustand, nicht eine Meldung: Sind alle
// Zahlungen zugeordnet, verschwindet der Knopf und die Gruppe meldet
// sich als vollständig.
await expect(eintrag).toContainText('Alle 7 Zahlungen sind dem Vertrag zugeordnet')
await expect(eintrag.getByRole('button', { name: /als belegt zuordnen/ })).toHaveCount(0)
})
test('zeigt die Zahlungen danach als durch Vertrag belegt', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const zeilen = page.locator('tr', { hasText: GESELLSCHAFT })
await expect(zeilen.first()).toBeVisible()
// Die passenden Raten gelten als belegt …
await expect(page.getByText('durch Vertrag belegt').first()).toBeVisible()
// … und keine davon steht mehr als „Beleg fehlt".
const zeileMitFehlt = page.locator('tr', { hasText: GESELLSCHAFT }).filter({
hasText: 'Beleg fehlt',
})
await expect(zeileMitFehlt).toHaveCount(0)
})
test('markiert die abweichende Zahlung zur Prüfung, statt sie durchzuwinken', async ({
page,
}) => {
// Die Zusage, auf der alles steht: Die Zuordnung darf keine Hintertür
// sein, durch die eine verzehnfachte Prämie unbemerkt als belegt gilt.
await anmelden(page)
await page.goto('/finanzen/buchungen')
const ausreisser = page.locator('tr', { hasText: '425,00' })
await expect(ausreisser).toContainText('Betrag prüfen')
})
test('nimmt die Zuordnung aus der Kachel auf „Heute“ heraus', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
// Die sechs zugeordneten Zahlungen dürfen dort nicht mehr auftauchen.
const kachel = page.locator('section, div').filter({ hasText: 'Unverbuchte Belege' }).last()
await expect(kachel).not.toContainText(GESELLSCHAFT)
})
test('fällt zurück in „Beleg fehlt“, wenn der Vertrag verschwindet', async ({ page }) => {
// Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre
// schlimmer als gar keiner.
await mitDb(async (db) => {
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
await anmelden(page)
await page.goto('/finanzen/buchungen')
const zeilen = page.locator('tr', { hasText: GESELLSCHAFT })
await expect(zeilen.first()).toContainText('Beleg fehlt')
})
})
+179
View File
@@ -0,0 +1,179 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Schreiben erzeugen — gegen die echte Anwendung.
*
* Der eigentliche Gewinn dieses Issues ist nicht der Brieftext (den gab es
* schon), sondern was danach passiert: Word-Datei, Ablage als
* Ausgangsdokument, Wiedervorlage. Genau dieser Teil lässt sich mit
* Einheitstests nicht prüfen — er berührt Objektspeicher, Datenbank und
* Download.
*/
const VERTRAG = 'Prüfhausen Police (Test Schreiben)'
const GESELLSCHAFT = 'Prüfhausen Lebensversicherung'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.dokument.deleteMany({ where: { titel: { contains: 'Rückkaufswert' } } })
await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
}
async function vertragAnlegen(): Promise<void> {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: VERTRAG,
gesellschaft: GESELLSCHAFT,
art: 'LEBENSVERSICHERUNG',
beitragCent: 4250n,
rhythmus: 'MONATLICH',
beginnAm: new Date(Date.UTC(2015, 0, 1)),
},
})
}
})
}
/**
* Findet die Vertrags-ID der AKTIVEN betreuten Person.
*
* Welche Person aktiv ist, haengt an einem Cookie — die Datenbank allein
* weiss es nicht. Der Weg ueber die Vertragsliste ist zugleich der Weg,
* den ein Mensch nimmt.
*/
async function vertragIdAusOberflaeche(page: import('@playwright/test').Page): Promise<string> {
await page.goto('/vertraege')
const link = page.getByRole('link', { name: new RegExp(VERTRAG.replace(/[()]/g, '.')) }).first()
await expect(link).toBeVisible()
const ziel = await link.getAttribute('href')
return (ziel ?? '').split('/').pop() ?? ''
}
test.describe('Schreiben erzeugen', () => {
test.beforeAll(async () => {
await aufraeumen()
await vertragAnlegen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('baut den Entwurf mit Bestellungsabsatz und lässt ihn bearbeiten', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const brief = page.locator('textarea#text')
await expect(brief).toBeVisible()
const text = await brief.inputValue()
// Ohne den Nachweis der Vertretungsmacht antwortet keine Stelle —
// deshalb steht er auch im freien Entwurf drin.
expect(text).toMatch(/Betreuungsgericht|bestellt/i)
// Und die Lücken sind sichtbar, nicht stillschweigend leer.
expect(text).toContain('[')
await brief.fill(`${text}\n\nZusatz aus dem Test.`)
expect(await brief.inputValue()).toContain('Zusatz aus dem Test.')
})
test('erzeugt aus dem Vertrag heraus einen Rückkaufswert-Entwurf', async ({ page }) => {
await anmelden(page)
const vertragId = await vertragIdAusOberflaeche(page)
expect(vertragId).not.toBe('')
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
await expect(page.locator('input#empfaengerName')).toHaveValue(GESELLSCHAFT)
await expect(page.locator('input#betreff')).not.toHaveValue('')
// Die Wiedervorlage ist vorbelegt — 28 Tage.
await expect(page.locator('input#wiedervorlageAm')).not.toHaveValue('')
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Rückkaufswert')
})
test('liefert die Word-Datei aus', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('input#empfaengerName').fill('Prüfhausen Amt')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Als Word-Datei' }).click(),
])
// Sprechender, sortierbarer Name mit Datum vorn.
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.docx$/)
expect(download.suggestedFilename()).toContain('Pruefhausen')
})
test('legt das Schreiben in die Akte und setzt die Wiedervorlage', async ({ page }) => {
await anmelden(page)
const vertragId = await vertragIdAusOberflaeche(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
await page.getByRole('button', { name: 'In die Akte legen' }).click()
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
timeout: 30_000,
})
await expect(page.getByText(/Wiedervorlage zum/)).toBeVisible()
// Und es steht wirklich in der Dokumentenliste, als Ausgang.
//
// Bewusst ohne tr/li: Die Liste rendert Tabelle UND Karten, je nach
// Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
// unsichtbare Fassung sähe wie ein Fehler aus.
await page.goto('/dokumente?richtung=AUSGANG')
// Die Zusammenfassungszeile gibt es nur einmal — anders als den Titel,
// der in Tabelle UND Kartenliste steht.
await expect(page.getByText('1 Dokument passen zu den Filtern')).toBeVisible()
await expect(page.locator('body')).toContainText('Auskunft über Rückkaufswert')
await expect(page.locator('body')).toContainText('Ausgang')
// Die Wiedervorlage steht in der Fristenliste.
await page.goto('/fristen')
await expect(page.getByText(/Antwort abwarten/).first()).toBeVisible()
})
test('legt aus einer freien Anschrift einen Kontakt an', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('input#empfaengerName').fill('Prüfhausen Sozialamt')
await page.locator('textarea#empfaengerAnschrift').fill('Musterweg 3\n96450 Coburg')
await page.getByRole('button', { name: 'Als Kontakt merken' }).click()
await expect(page.getByText(/steht jetzt in den Kontakten/)).toBeVisible()
// Beim nächsten Mal steht er zur Auswahl.
await page.goto('/schreiben/neu?art=FREI')
await expect(page.locator('select#kontaktAuswahl')).toContainText('Prüfhausen Sozialamt')
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.2.0",
"version": "1.4.0",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
@@ -0,0 +1,19 @@
-- Dauerzahlungen: Buchung.vertragId.
--
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen.
-- AlterTable
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "vertragId" TEXT;
-- CreateIndex
CREATE INDEX "buchung_vertragId_idx" ON "buchung"("vertragId");
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_vertragId_fkey" FOREIGN KEY ("vertragId") REFERENCES "vertrag"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+17
View File
@@ -890,6 +890,21 @@ model Buchung {
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
belegFehltBegruendung String?
/// Dauerzahlung zu einem Vertrag. Bei einem Dauerschuldverhaeltnis IST
/// der Vertrag der Beleg - fuer zwoelf gleiche Lastschriften gibt es
/// keine zwoelf Quittungen, sondern eine Police.
///
/// onDelete: SetNull ist Absicht und Teil der Zusage: Verschwindet der
/// Vertrag, faellt die Buchung zurueck in "Beleg fehlt". Ein Nachweis,
/// der auf etwas zeigt, das es nicht mehr gibt, waere schlimmer als
/// gar keiner.
vertragId String?
vertrag Vertrag? @relation("Vertragszahlung", fields: [vertragId], references: [id], onDelete: SetNull)
/// Wurde die Zuordnung beim Import automatisch gesetzt? Dann ist sie
/// eine Vermutung der Maschine und in der Liste als solche erkennbar.
vertragAutomatisch Boolean @default(false)
/// false = Auslage des Betreuers, die erstattet werden muss.
betrifftBetreute Boolean @default(true)
/// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende
@@ -908,6 +923,7 @@ model Buchung {
@@unique([kontoId, importReferenz])
@@index([betreuteId, datum])
@@index([betreuteId, kategorie])
@@index([vertragId])
@@map("buchung")
}
@@ -1173,6 +1189,7 @@ model Vertrag {
vermoegensposten Vermoegensposten[]
verknuepfungen DokumentVerknuepfung[]
zahlungen Buchung[] @relation("Vertragszahlung")
@@index([betreuteId, status])
@@map("vertrag")
@@ -26,7 +26,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Buchung', buchung)
const [konten, belege] = await Promise.all([
const [konten, belege, vertraege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -38,6 +38,11 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
])
return (
@@ -64,6 +69,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
betreuteId={betreute.id}
konten={konten}
belege={belege}
vertraege={vertraege}
buchung={{
id: buchung.id,
kontoId: buchung.kontoId,
@@ -75,6 +81,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
gegenstelle: buchung.gegenstelle,
verwendungszweck: offen.verwendungszweck,
belegId: buchung.belegId,
vertragId: buchung.vertragId,
belegFehltBegruendung: buchung.belegFehltBegruendung,
betrifftBetreute: buchung.betrifftBetreute,
vermoegenswirksam: buchung.vermoegenswirksam,
@@ -30,7 +30,7 @@ export default async function BuchungNeuSeite({
const schnell = parameter.schnell === '1'
const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null
const [konten, belege] = await Promise.all([
const [konten, belege, vertraege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -42,6 +42,11 @@ export default async function BuchungNeuSeite({
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
])
return (
@@ -93,6 +98,7 @@ export default async function BuchungNeuSeite({
: konten
}
belege={belege}
vertraege={vertraege}
schnellerfassung={schnell}
standardDatum={tagAlsText(heute())}
/>
+9 -26
View File
@@ -9,10 +9,10 @@ import { Rechtshinweis } from '@/components/rechtshinweis'
import { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine'
import { Buchungsfilter } from '@/components/finanzen/buchungs-filter'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch'
import { BUCHUNG_EINBEZUG, zuBuchungEingabe } from '@/lib/berichte/gemeinsam'
import { belegDurchVertrag, betragPruefen, belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { buchungsfilterSchema } from '@/lib/validierung/finanzen'
@@ -82,10 +82,7 @@ export default async function BuchungenSeite({
const [zeilen, gesamt, konten] = await Promise.all([
db.buchung.findMany({
where: bedingung,
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
include: BUCHUNG_EINBEZUG,
orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }],
take: HOECHSTZAHL,
}),
@@ -97,26 +94,7 @@ export default async function BuchungenSeite({
}),
])
const buchungen: BuchungEingabe[] = zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
const buchungen: BuchungEingabe[] = zeilen.map(zuBuchungEingabe)
const zahlen = kennzahlen(buchungen)
@@ -261,6 +239,11 @@ export default async function BuchungenSeite({
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/>
</TableCell>
<TableCell className="text-right align-top">
+13 -4
View File
@@ -17,7 +17,7 @@ import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch'
import { belegDurchVertrag, betragPruefen, kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam'
@@ -121,9 +121,13 @@ export default async function FinanzenSeite() {
titel="Belegquote (12 Monate)"
wert={`${zahlen.beleg.quoteProzent} %`}
hinweis={
zahlen.beleg.ohneNachweis > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung`
: 'Alle Buchungen sind belegt oder begründet'
zahlen.beleg.zuPruefen > 0
? `${zahlen.beleg.zuPruefen} Zahlung(en) weichen vom hinterlegten Beitrag ab`
: zahlen.beleg.ohneNachweis > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung`
: zahlen.beleg.durchVertrag > 0
? `Belegt, begründet oder durch Vertrag gedeckt (${zahlen.beleg.durchVertrag} Dauerzahlungen)`
: 'Alle Buchungen sind belegt oder begründet'
}
ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
href="/finanzen/buchungen?nurOhneBeleg=on"
@@ -318,6 +322,11 @@ export default async function FinanzenSeite() {
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/>
</TableCell>
<TableCell className="text-right">
+34 -15
View File
@@ -9,6 +9,7 @@ import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Betrag } from '@/components/finanzen/bausteine'
import {
AlsGeklaertAbhaken,
ZahlungenZuordnen,
Zurueckholen,
} from '@/components/finanzen/wiederkehrend-aktionen'
import { finanzKontext } from '@/lib/finanzen/seite'
@@ -245,22 +246,40 @@ export default async function WiederkehrendSeite() {
<CardContent>
<ul className="divide-y text-sm">
{mitVertrag.map((gruppe) => (
<li
key={gruppe.schluessel}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
<span className="font-medium">{gruppe.anzeigename}</span>
<span className="ml-2 text-muted-foreground">
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '}
{formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr
<li key={gruppe.schluessel} className="space-y-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span>
<span className="font-medium">{gruppe.anzeigename}</span>
<span className="ml-2 text-muted-foreground">
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '}
{formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr
</span>
</span>
</span>
<Button asChild variant="ghost" size="sm">
<Link href={`/vertraege/${gruppe.vertragId}`}>
{gruppe.vertragBezeichnung}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href={`/vertraege/${gruppe.vertragId}`}>
{gruppe.vertragBezeichnung}
</Link>
</Button>
</div>
{/* Der Vertrag liegt vor - dann belegt er auch die
Zahlungen. Einzeln anzuklicken waeren bei drei
Jahren Laufzeit 36 Klicks. */}
{schreiben && gruppe.offeneBuchungIds.length > 0 && (
<ZahlungenZuordnen
betreuteId={betreute.id}
vertragId={gruppe.vertragId!}
vertragBezeichnung={gruppe.vertragBezeichnung ?? 'Vertrag'}
buchungIds={gruppe.offeneBuchungIds}
/>
)}
{gruppe.offeneBuchungIds.length === 0 && (
<p className="text-xs text-ampel-gruen">
Alle {gruppe.anzahl} Zahlungen sind dem Vertrag zugeordnet und damit
belegt.
</p>
)}
</li>
))}
</ul>
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { briefDocxErzeugen } from '@/lib/schreiben/docx'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
/**
* Das Schreiben als Word-Datei herunterladen.
*
* Warum eine Route und keine Server Action? Eine Action kann keinen
* Download ausloesen - sie muesste die Datei kodiert zurueckgeben und der
* Browser sie nachbauen. Eine Route liefert sie unmittelbar aus, mit
* richtigem Namen und Typ. Denselben Weg geht schon der Vollexport.
*
* POST und nicht GET, weil der bearbeitete Brieftext mitkommt und in eine
* Adresszeile weder hineinpasst noch hineingehoert: Er stuende sonst im
* Verlauf des Browsers und in jedem Zwischenspeicher.
*/
export const dynamic = 'force-dynamic'
const MIME_DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
export async function POST(anfrage: Request): Promise<Response> {
let formular: FormData
try {
formular = await anfrage.formData()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const betreuteId = String(formular.get('betreuteId') ?? '')
const rohArt = String(formular.get('art') ?? '')
const text = String(formular.get('text') ?? '')
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
const versandRoh = String(formular.get('versandAm') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
try {
// Zugriff wird hier geprueft und nicht nur auf der Seite davor: Eine
// Route laesst sich unmittelbar aufrufen.
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const datum = tagAusText(versandRoh) ?? heuteAlsTag()
const datei = await briefDocxErzeugen(text)
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, datum, 'docx')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': MIME_DOCX,
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
// Ein Schreiben aus einer Betreuungsakte gehoert in keinen
// Zwischenspeicher.
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+121
View File
@@ -0,0 +1,121 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, Scale } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { SchreibenEditor } from '@/components/schreiben/schreiben-editor'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { entwurfBauen } from '@/lib/schreiben/entwurf'
import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten'
export const metadata: Metadata = { title: 'Schreiben' }
export const dynamic = 'force-dynamic'
/**
* Ein Schreiben erzeugen, bearbeiten, ausgeben und ablegen.
*
* Eine Seite für alle Arten und alle drei Empfängerwege — statt die
* Bearbeitung dreimal an Vertrag, Vorgang und Freitext nachzubauen. Die
* bestehenden Seiten verlinken hierher.
*
* Der Fall ohne Bezug ist ausdrücklich vorgesehen: Am Anfang einer
* Betreuung schreibt man an Stellen, zu denen es noch keinen Datensatz
* gibt — man fragt ja gerade an, um zu erfahren, ob dort etwas besteht.
*/
export default async function SchreibenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const benutzer = await erfordereBenutzer('/schreiben/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
if (!darfSchreiben('DOKUMENTE')) redirect('/dokumente')
const text = (name: string): string => {
const wert = parameter[name]
return typeof wert === 'string' ? wert.trim() : ''
}
const rohArt = text('art')
const art = istSchreibenArt(rohArt) ? rohArt : 'FREI'
const eigenschaften = SCHREIBEN_EIGENSCHAFTEN[art]
let entwurf
try {
entwurf = await entwurfBauen({
betreuteId: aktive.id,
benutzerName: benutzer.name,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
})
} catch (ausnahme) {
// Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine
// Angabe, die nicht stimmt. Die Seite sagt es und bietet den freien
// Weg an, statt den Benutzer vor eine Fehlerseite zu setzen.
const meldung =
ausnahme instanceof Error ? ausnahme.message : 'Der Entwurf ließ sich nicht bauen.'
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf titel="Schreiben" beschreibung={eigenschaften.beschriftung} />
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>
{meldung} Du kannst stattdessen ein{' '}
<a href="/schreiben/neu?art=FREI" className="underline">
freies Schreiben
</a>{' '}
erzeugen.
</AlertDescription>
</Alert>
</div>
)
}
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true, strasse: true, plz: true, ort: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 200,
})
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel={eigenschaften.beschriftung}
beschreibung="Bearbeiten, als Word-Datei herunterladen, in die Akte legen — und eine Wiedervorlage setzen, damit das Warten nicht vergessen geht."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]}
/>
<SchreibenEditor
betreuteId={aktive.id}
art={art}
entwurf={{
betreff: entwurf.betreff,
text: entwurf.text,
empfaengerName: entwurf.empfaengerName,
wiedervorlageAm: entwurf.wiedervorlageAm,
}}
kontakte={kontakte}
vertragId={entwurf.vertragId}
vorgangId={entwurf.vorgangId}
fristBegruendung={eigenschaften.fristBegruendung}
warnungen={entwurf.warnungen}
/>
<Alert variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>
Der vorgeschlagene Wortlaut ist ein Entwurf, keine Rechtsberatung. Bei einem
genehmigungspflichtigen Geschäft gehört die Genehmigung des Betreuungsgerichts vor den
Versand — nicht danach.
</AlertDescription>
</Alert>
</div>
)
}
+387 -379
View File
@@ -1,379 +1,387 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import {
GenehmigungsKette,
type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import {
VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen,
type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils'
/**
* Die Fallakte.
*
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: {
where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' },
},
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } },
},
})
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
const protokolle = await db.auditLog.findMany({
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
orderBy: { zeitpunkt: 'asc' },
take: 200,
})
const statuswechsel: ZeitstrahlStatuswechsel[] = []
for (const eintrag of protokolle) {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
if (!roh) continue
try {
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status
if (!status || typeof status.neu !== 'string') continue
statuswechsel.push({
id: eintrag.id,
zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
nach: status.neu as VorgangStatus,
wer: eintrag.userName,
})
} catch {
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
continue
}
}
// --- Zeitstrahl -----------------------------------------------------------
const zeitstrahl = zeitstrahlBauen({
vorgang: {
titel: vorgang.titel,
erstelltAm: vorgang.erstelltAm,
erledigtAm: vorgang.erledigtAm,
},
dokumente: vorgang.verknuepfungen
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null)
.map((d) => ({
id: d.id,
titel: d.titel,
eingangAm: d.eingangAm,
versandAm: d.versandAm,
erstelltAm: d.erstelltAm,
absender: d.absender,
empfaenger: d.empfaenger,
})),
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
const klar = oeffnen('Kontaktprotokoll', p)
return {
id: p.id,
datum: p.datum,
art: p.art,
gespraechspartner: p.gespraechspartner,
mitBetreuter: p.mitBetreuter,
inhalt: klar.inhalt ?? null,
}
}),
fristen: vorgang.fristen.map((f) => ({
id: f.id,
titel: f.titel,
datum: f.datum,
erledigtAm: f.erledigtAm,
ausschlussfrist: f.ausschlussfrist,
rechtsgrundlage: f.rechtsgrundlage,
})),
buchungen: vorgang.buchungen.map((b) => {
const klar = oeffnen('Buchung', b)
return {
id: b.id,
datum: b.datum,
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
gegenstelle: b.gegenstelle,
verwendungszweck: klar.verwendungszweck ?? null,
}
}),
genehmigungen: vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
})),
statuswechsel,
})
// --- Sachstandsanfrage ----------------------------------------------------
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
// Knopf nur Zierde.
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
const anfrage = wartetAufDritte
? sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: aktive.vorname,
nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
},
betreuer: {
name: benutzer.name,
aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht,
},
})
: null
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 100,
})
: []
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
beschreibung: a.beschreibung,
erledigt: a.erledigt,
faelligAm: a.faelligAm,
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
id: g.id,
titel: g.titel,
rechtsgrundlage: g.rechtsgrundlage,
status: g.status,
mitteilungAm: g.mitteilungAm,
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm,
beschlussAm: g.beschlussAm,
rechtskraftAm: g.rechtskraftAm,
vollzugAm: g.vollzugAm,
beschlussDokumentId: g.beschlussDokumentId,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
dokumente,
}))
return (
<div className="space-y-8">
<SeitenKopf
titel={vorgang.titel}
beschreibung={offen.beschreibung ?? undefined}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: vorgang.titel },
]}
aktionen={
<>
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
{anfrage && (
<SachstandsanfrageKnopf
vorgangId={vorgang.id}
betreff={anfrage.betreff}
text={anfrage.text}
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
/>
{/* --- Kopf der Akte ---------------------------------------------- */}
<Card>
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Aktenzeichen</p>
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Einordnung</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
<Badge variant="secondary">
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
</Badge>
<Faelligkeit datum={vorgang.faelligAm} />
</div>
</div>
</CardContent>
</Card>
{/* --- Aufgaben ----------------------------------------------------- */}
<section aria-labelledby="aufgaben" className="space-y-4">
<h2 id="aufgaben" className="text-xl font-semibold">
Aufgaben
</h2>
<AufgabenListe
vorgangId={vorgang.id}
aufgaben={aufgaben}
darfSchreiben={schreiben}
/>
</section>
{/* --- Genehmigungen ------------------------------------------------ */}
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
<section aria-labelledby="genehmigungen" className="space-y-6">
<h2 id="genehmigungen" className="text-xl font-semibold">
Genehmigung des Betreuungsgerichts
</h2>
{genehmigungen.map((genehmigung) => (
<GenehmigungsKette
key={genehmigung.id}
genehmigung={genehmigung}
darfSchreiben={schreiben}
/>
))}
{schreiben && genehmigungen.length === 0 && (
<GenehmigungAnlegen vorgangId={vorgang.id} />
)}
</section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import Link from 'next/link'
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
import { AlertTriangle, FileCheck, FileWarning, Paperclip } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils'
@@ -104,10 +104,19 @@ export function Belegzeichen({
belegTitel,
begruendung,
belegId,
vertragBezeichnung = null,
vertragAutomatisch = false,
betragZuPruefen = false,
}: {
belegId: string | null
belegTitel: string | null
begruendung: string | null
/** Dauerzahlung: gesetzt, wenn der Vertrag als Beleg gilt. */
vertragBezeichnung?: string | null
/** Beim Import automatisch zugeordnet - eine Vermutung, kein Urteil. */
vertragAutomatisch?: boolean
/** Zugeordnet, aber der Betrag weicht ab. */
betragZuPruefen?: boolean
}) {
if (belegId) {
return (
@@ -118,6 +127,36 @@ export function Belegzeichen({
)
}
// Zugeordnet, aber der Betrag passt nicht. Steht vor allem anderen: Das
// ist der Fall, den jemand ansehen soll.
if (betragZuPruefen) {
return (
<Badge
variant="rot"
title="Der Betrag weicht deutlich vom hinterlegten Beitrag ab. Meist eine Beitragsanpassung, die am Vertrag noch fehlt."
>
<AlertTriangle className="size-3.5" aria-hidden="true" />
Betrag prüfen
</Badge>
)
}
if (vertragBezeichnung) {
return (
<Badge
variant="gruen"
title={
vertragAutomatisch
? `Beim Import automatisch dem Vertrag „${vertragBezeichnung}“ zugeordnet. Bitte einmal durchsehen.`
: `Dauerzahlung zu Vertrag „${vertragBezeichnung}“.`
}
>
<FileCheck className="size-3.5" aria-hidden="true" />
{vertragAutomatisch ? 'Vertrag (automatisch)' : 'durch Vertrag belegt'}
</Badge>
)
}
if (begruendung && begruendung.trim() !== '') {
return (
<Badge variant="gelb" title={begruendung}>
+34 -1
View File
@@ -33,6 +33,12 @@ export type BelegAuswahl = {
titel: string
}
export type VertragAuswahl = {
id: string
bezeichnung: string
gesellschaft: string | null
}
export type BuchungWerte = {
id: string
kontoId: string | null
@@ -45,6 +51,7 @@ export type BuchungWerte = {
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
vertragId: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
@@ -65,6 +72,7 @@ export function Buchungsformular({
betreuteId,
konten,
belege,
vertraege,
buchung,
standardDatum,
schnellerfassung = false,
@@ -72,6 +80,7 @@ export function Buchungsformular({
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
vertraege: VertragAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
@@ -87,6 +96,7 @@ export function Buchungsformular({
)
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
@@ -275,7 +285,30 @@ export function Buchungsformular({
</Select>
</Feld>
{belegId === '' && (
{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?"
@@ -2,12 +2,13 @@
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2, Undo2, XCircle } from 'lucide-react'
import { AlertCircle, CheckCircle2, Link2, Undo2, XCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
zahlungenVertragZuordnen,
zahlungsgruppeAbhaken,
zahlungsgruppeZurueckholen,
type Formularzustand,
@@ -126,3 +127,51 @@ export function Rueckmeldung({ text }: { text: string | null }) {
</Alert>
)
}
/**
* Ordnet alle noch offenen Buchungen einer Gruppe dem Vertrag zu.
*
* Der Sinn dieses Knopfes ist die Menge: Eine monatliche Prämie über drei
* Jahre sind 36 Buchungen. Sie einzeln anzuklicken wäre genau die stumpfe
* Arbeit, die diese Zuordnung abschaffen soll.
*/
export function ZahlungenZuordnen({
betreuteId,
vertragId,
vertragBezeichnung,
buchungIds,
}: {
betreuteId: string
vertragId: string
vertragBezeichnung: string
buchungIds: string[]
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungenVertragZuordnen,
{},
)
if (buchungIds.length === 0) return null
return (
<form action={absenden} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<input type="hidden" name="buchungIds" value={buchungIds.join(',')} />
{zustand.fehler && (
<span className="text-xs text-destructive">{zustand.fehler}</span>
)}
{zustand.erfolg && (
<span className="text-xs text-ampel-gruen">{zustand.erfolg}</span>
)}
<Absendeknopf variant="outline" size="sm" wartetext="Wird zugeordnet …">
<Link2 aria-hidden="true" />
{buchungIds.length} Zahlung{buchungIds.length === 1 ? '' : 'en'} als belegt zuordnen
</Absendeknopf>
<span className="text-xs text-muted-foreground">zu „{vertragBezeichnung}“</span>
</form>
)
}
@@ -0,0 +1,356 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import Link from 'next/link'
import {
AlertCircle,
CheckCircle2,
Copy,
FileDown,
FolderInput,
Info,
UserPlus,
} from 'lucide-react'
import { Alert, AlertDescription } 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 { Textarea } from '@/components/ui/textarea'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
kontaktAusAnschrift,
schreibenAblegen,
type SchreibenZustand,
} from '@/server/actions/schreiben'
import type { SchreibenArt } from '@/lib/schreiben/arten'
export type KontaktAuswahl = {
id: string
name: string
organisation: string | null
strasse: string | null
plz: string | null
ort: string | null
}
/**
* Schreiben bearbeiten, herunterladen, ablegen.
*
* Der Text steht in einem gewöhnlichen Textfeld und ist vollständig
* bearbeitbar. Das ist Absicht: Jeder Fall hat eine Besonderheit, die in
* den Brief gehört. Ein Formulartext, den man nicht mehr ändern kann,
* wird früher oder später falsch abgeschickt.
*/
export function SchreibenEditor({
betreuteId,
art,
entwurf,
kontakte,
vertragId,
vorgangId,
fristBegruendung,
warnungen,
}: {
betreuteId: string
art: SchreibenArt
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string }
kontakte: KontaktAuswahl[]
vertragId: string | null
vorgangId: string | null
fristBegruendung: string | null
warnungen: string[]
}) {
const [text, setText] = React.useState(entwurf.text)
const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('')
const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10))
const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
const [kopiert, setKopiert] = React.useState(false)
const [ablageZustand, ablegen] = useActionState<SchreibenZustand, FormData>(
schreibenAblegen,
{},
)
const [kontaktZustand, kontaktAnlegen] = useActionState<SchreibenZustand, FormData>(
kontaktAusAnschrift,
{},
)
/** Gemeinsame versteckte Felder für beide Formulare und den Download. */
function VersteckteFelder() {
return (
<>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="art" value={art} />
<input type="hidden" name="betreff" value={betreff} />
<input type="hidden" name="text" value={text} />
<input type="hidden" name="empfaengerName" value={empfaengerName} />
<input type="hidden" name="empfaengerAnschrift" value={empfaengerAnschrift} />
<input type="hidden" name="versandAm" value={versandAm} />
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
<input type="hidden" name="vertragId" value={vertragId ?? ''} />
<input type="hidden" name="vorgangId" value={vorgangId ?? ''} />
</>
)
}
function kontaktUebernehmen(id: string) {
const kontakt = kontakte.find((k) => k.id === id)
if (!kontakt) return
setEmpfaengerName(kontakt.organisation ?? kontakt.name)
setEmpfaengerAnschrift(
[kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')]
.filter(Boolean)
.join('\n'),
)
}
async function textKopieren() {
try {
await navigator.clipboard.writeText(text)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 2500)
} catch {
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
}
}
const abgelegt = Boolean(ablageZustand.erfolg)
return (
<div className="space-y-6">
{warnungen.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertDescription>
<p className="font-medium">Vor dem Abschicken klären:</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{warnungen.map((warnung) => (
<li key={warnung}>{warnung}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* --- Empfänger ---------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">An wen geht das Schreiben?</CardTitle>
<CardDescription>
Der Empfänger steht im Brieftext darunter — was du hier einträgst, landet am Dokument
in der Akte und im Dateinamen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kontakte.length > 0 && (
<Feld
id="kontaktAuswahl"
beschriftung="Aus den Kontakten übernehmen"
erklaerung="Spart das Abtippen, wenn die Stelle schon im System steht."
>
<Select
id="kontaktAuswahl"
defaultValue=""
onChange={(ereignis) => kontaktUebernehmen(ereignis.target.value)}
>
<option value="">– Kontakt wählen –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{kontakt.organisation ?? kontakt.name}
{kontakt.ort ? ` · ${kontakt.ort}` : ''}
</option>
))}
</Select>
</Feld>
)}
<Feld id="empfaengerName" beschriftung="Name oder Gesellschaft" pflicht>
<Input
id="empfaengerName"
value={empfaengerName}
onChange={(ereignis) => setEmpfaengerName(ereignis.target.value)}
maxLength={200}
placeholder="z. B. Allianz Lebensversicherungs-AG"
/>
</Feld>
<Feld
id="empfaengerAnschrift"
beschriftung="Anschrift"
erklaerung="Straße, dann PLZ und Ort — je Zeile eine Angabe. Nur nötig, wenn du daraus einen Kontakt anlegen willst; in den Brieftext trägst du sie unten selbst ein."
>
<Textarea
id="empfaengerAnschrift"
value={empfaengerAnschrift}
onChange={(ereignis) => setEmpfaengerAnschrift(ereignis.target.value)}
rows={3}
maxLength={500}
/>
</Feld>
{empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && (
<form action={kontaktAnlegen}>
<VersteckteFelder />
{kontaktZustand.fehler && (
<Alert variant="fehler" className="mb-2">
<AlertCircle aria-hidden="true" />
<AlertDescription>{kontaktZustand.fehler}</AlertDescription>
</Alert>
)}
{kontaktZustand.erfolg && (
<Alert variant="erfolg" className="mb-2">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{kontaktZustand.erfolg}</AlertDescription>
</Alert>
)}
<Absendeknopf variant="outline" size="sm" wartetext="Wird angelegt …">
<UserPlus aria-hidden="true" />
Als Kontakt merken
</Absendeknopf>
</form>
)}
</CardContent>
</Card>
{/* --- Der Brief ---------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Der Brief</CardTitle>
<CardDescription>
Vollständig bearbeitbar. Was hier steht, steht auch in der Word-Datei und in der
Fassung für die Akte.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Feld
id="betreff"
beschriftung="Betreff"
erklaerung="Wird zum Titel des Dokuments in der Akte."
pflicht
>
<Input
id="betreff"
value={betreff}
onChange={(ereignis) => setBetreff(ereignis.target.value)}
maxLength={200}
/>
</Feld>
<Feld id="text" beschriftung="Brieftext" pflicht>
<Textarea
id="text"
value={text}
onChange={(ereignis) => setText(ereignis.target.value)}
rows={26}
className="font-mono text-sm leading-relaxed"
spellCheck
/>
</Feld>
<Button type="button" variant="ghost" size="sm" onClick={() => void textKopieren()}>
<Copy aria-hidden="true" />
{kopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
</CardContent>
</Card>
{/* --- Ausgabe ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Und jetzt?</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="versandAm"
beschriftung="Versandt am"
erklaerung="Steht am Dokument in der Akte."
>
<Input
id="versandAm"
type="date"
value={versandAm}
onChange={(ereignis) => setVersandAm(ereignis.target.value)}
/>
</Feld>
<Feld
id="wiedervorlageAm"
beschriftung="Wiedervorlage am"
erklaerung={fristBegruendung ?? 'Leer lassen, wenn du keine Antwort erwartest.'}
>
<Input
id="wiedervorlageAm"
type="date"
value={wiedervorlageAm}
onChange={(ereignis) => setWiedervorlageAm(ereignis.target.value)}
/>
</Feld>
</div>
{/* Word-Datei: eigene Route, damit der Browser einen echten
Download bekommt. */}
<form action="/schreiben/docx" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als Word-Datei
</Button>
</form>
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der
Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die Word-Datei
ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab — dann
lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann ablegen.
</AlertDescription>
</Alert>
{ablageZustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{ablageZustand.fehler}</AlertDescription>
</Alert>
)}
{abgelegt && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>
<p className="font-medium">{ablageZustand.erfolg}</p>
{ablageZustand.meldung && (
<ul className="mt-1 list-disc space-y-1 pl-4">
{ablageZustand.meldung.map((zeile) => (
<li key={zeile}>{zeile}</li>
))}
</ul>
)}
<p className="mt-2">
<Link href="/dokumente?richtung=AUSGANG" className="underline">
Zu den ausgehenden Dokumenten
</Link>
</p>
</AlertDescription>
</Alert>
)}
<form action={ablegen}>
<VersteckteFelder />
<Absendeknopf disabled={abgelegt} wartetext="Wird abgelegt …">
<FolderInput aria-hidden="true" />
{abgelegt ? 'Liegt in der Akte' : 'In die Akte legen'}
</Absendeknopf>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,7 +1,8 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { CheckCircle2, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
import { CheckCircle2, FileDown, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
@@ -10,6 +11,7 @@ import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
import { kuendigungVermerken } from '@/server/actions/vertraege'
import { formatDatum } from '@/lib/utils'
@@ -161,6 +163,15 @@ export function KuendigungsAssistent({
beschriftung="Kündigungsschreiben"
hoehe="min-h-[36rem]"
/>
<div className="mt-4">
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=KUENDIGUNG&vertragId=${vertragId}`}>
<FileDown aria-hidden="true" />
Bearbeiten, als Word-Datei und in die Akte
</Link>
</Button>
</div>
</CardContent>
</Card>
+11 -1
View File
@@ -1,7 +1,8 @@
'use client'
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { Coins, Mail, Plus } from 'lucide-react'
import { Coins, FileDown, Mail, Plus } 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'
@@ -149,6 +150,15 @@ export function Rueckkaufswert({
hoehe="min-h-[32rem]"
/>
{/* Der Weg, der nicht beim Kopieren endet: bearbeiten,
Word-Datei, Ablage in der Akte, Wiedervorlage. */}
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`}>
<FileDown aria-hidden="true" />
Bearbeiten, als Word-Datei und in die Akte
</Link>
</Button>
<form action={anfrageAktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
+59 -24
View File
@@ -1,4 +1,5 @@
import 'server-only'
import type { Prisma } from '@prisma/client'
import type { Betreute, User } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
@@ -85,6 +86,62 @@ export function berichtskopf(eingabe: {
// Laden
// -----------------------------------------------------------------------------
/**
* Was eine Buchung mitbringen muss, damit sich daraus eine BuchungEingabe
* bauen laesst.
*
* Steht hier und nicht in jeder Seite einzeln: Die Buchungsliste hatte
* lange eine eigene, fast gleiche Abfrage. Als die Dauerzahlungen
* dazukamen, wurde nur eine der beiden nachgezogen - und in der Liste
* stand weiter "Beleg fehlt", obwohl der Vertrag hinterlegt war. Ein
* gemeinsamer Einbezug kann nicht halb vergessen werden.
*/
export const BUCHUNG_EINBEZUG = {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
// Bei einer Dauerzahlung ist der Vertrag der Beleg, und sein Beitrag ist
// der Massstab fuer die Betragspruefung.
vertrag: {
select: {
id: true,
bezeichnung: true,
beitragCent: true,
rhythmus: true,
vertragsnummerVerschluesselt: true,
},
},
} as const
type BuchungMitBezug = Prisma.BuchungGetPayload<{ include: typeof BUCHUNG_EINBEZUG }>
/** Wandelt eine geladene Zeile in die Form um, mit der die Auswertung rechnet. */
export function zuBuchungEingabe(zeile: BuchungMitBezug): BuchungEingabe {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
vertragId: zeile.vertragId,
vertragBezeichnung: zeile.vertrag?.bezeichnung ?? null,
vertragsnummer: zeile.vertrag ? oeffnen('Vertrag', zeile.vertrag).vertragsnummer : null,
vertragBeitragCent: zeile.vertrag?.beitragCent ?? null,
vertragRhythmus: zeile.vertrag?.rhythmus ?? null,
vertragAutomatisch: zeile.vertragAutomatisch,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
}
/**
* Alle Buchungen einer betreuten Person, entschluesselt und um die
* Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel,
@@ -102,33 +159,11 @@ export async function buchungenLaden(
? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } }
: {}),
},
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
include: BUCHUNG_EINBEZUG,
orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }],
})
return zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
return zeilen.map(zuBuchungEingabe)
}
/** Konten mit ihrer vollstaendigen Saldostand-Historie. */
+9
View File
@@ -444,6 +444,15 @@ export function RechnungslegungDokument({
<View style={stile.mittel}>
{beleg.belegTitel ? (
<Text style={stile.klein}>{beleg.belegTitel}</Text>
) : beleg.vertragBezeichnung ? (
<Text style={stile.klein}>
Dauerzahlung zu Vertrag „{beleg.vertragBezeichnung}“
{beleg.vertragsnummer ? `, Nr. ${beleg.vertragsnummer}` : ''}
</Text>
) : beleg.betragZuPruefen ? (
<Text style={[stile.klein, stile.fett]}>
Beleg fehlt – Betrag weicht vom Vertrag ab
</Text>
) : beleg.belegFehltBegruendung ? (
<Text style={stile.klein}>
ohne Beleg – {beleg.belegFehltBegruendung}
+22 -2
View File
@@ -167,7 +167,17 @@ export async function dashboardLaden(
// 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang.
darfFinanzen
? db.buchung.findMany({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
where: {
betreuteId,
belegId: null,
belegFehltBegruendung: null,
// Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren
// nicht in die Liste der offenen Belege. Die Betragspruefung
// laeuft erst spaeter auf den geladenen Zeilen; hier wird nur
// grob vorgefiltert.
vertragId: null,
...nichtGeloescht,
},
orderBy: { datum: 'desc' },
take: KACHEL_ZEILEN,
select: {
@@ -182,7 +192,17 @@ export async function dashboardLaden(
: null,
darfFinanzen
? db.buchung.count({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
where: {
betreuteId,
belegId: null,
belegFehltBegruendung: null,
// Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren
// nicht in die Liste der offenen Belege. Die Betragspruefung
// laeuft erst spaeter auf den geladenen Zeilen; hier wird nur
// grob vorgefiltert.
vertragId: null,
...nichtGeloescht,
},
})
: null,
+126 -5
View File
@@ -1,4 +1,4 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import type { BuchungKategorie, BuchungRichtung, ZahlRhythmus } from '@prisma/client'
import { summe, teileGerundet } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum'
import {
@@ -39,6 +39,15 @@ export type BuchungEingabe = {
belegId: string | null
belegTitel?: string | null
belegFehltBegruendung: string | null
/** Dauerzahlung zu einem Vertrag - dann ist der Vertrag der Beleg. */
vertragId?: string | null
vertragBezeichnung?: string | null
vertragsnummer?: string | null
/** Hinterlegter Beitrag samt Zahlweise - Grundlage der Betragspruefung. */
vertragBeitragCent?: bigint | null
vertragRhythmus?: ZahlRhythmus | null
/** Wurde die Zuordnung beim Import automatisch gesetzt? */
vertragAutomatisch?: boolean
betrifftBetreute: boolean
vermoegenswirksam: boolean
/** Laeuft die Buchung ueber ein Konto des Betreuers? */
@@ -54,24 +63,134 @@ export function istVermoegensbuchung(buchung: BuchungEingabe): boolean {
// Belegzwang
// -----------------------------------------------------------------------------
/**
* Wie weit darf die Zahlung vom hinterlegten Beitrag abweichen?
*
* Ein Fuenftel. Das ist grosszuegig und mit Absicht: Beitragsanpassungen,
* Versicherungsteuer und ein nachtraeglich geaenderter Selbstbehalt bewegen
* sich in dieser Groessenordnung, und bei jeder davon soll die Anwendung
* nicht meckern.
*
* Was sie fangen soll, ist die andere Groessenordnung: die Praemie, die von
* 42 auf 420 Euro springt, oder die Lastschrift, die versehentlich zweimal
* lief. Wer die Grenze enger zieht, bekommt taeglich Warnungen und sieht
* die eine wichtige nicht mehr.
*/
export const BETRAGS_TOLERANZ_PROZENT = 20n
/**
* Passt der Betrag zu dem, was am Vertrag steht?
*
* Verglichen wird gegen den hinterlegten Beitrag, unveraendert. Kein
* Umrechnen auf andere Zahlweisen.
*
* Der erste Entwurf tat genau das - Jahresbetrag bilden und gegen
* jaehrlich, halbjaehrlich, vierteljaehrlich und monatlich pruefen, damit
* es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt.
* Das Ergebnis war ein Toleranzband mit vier Loechern: Bei einem
* Monatsbeitrag von 42,50 EUR liegen 420 EUR innerhalb von 20 Prozent des
* Jahresbetrags von 510 EUR. Ausgerechnet der Fall, vor dem diese Pruefung
* schuetzen soll, waere durchgerutscht.
*
* Bucht die Bank tatsaechlich jaehrlich ab, waehrend am Vertrag "monatlich"
* steht, dann stimmen die Vertragsdaten nicht - und dann soll die
* Buchung als "zu pruefen" auffallen, statt stillschweigend akzeptiert zu
* werden. Die Zahlweise am Vertrag zu berichtigen dauert zehn Sekunden und
* macht die Akte richtiger.
*
* Ohne hinterlegten Beitrag gibt es nichts zu vergleichen - dann lautet die
* Antwort "unbekannt" (null), nicht "in Ordnung". Der Unterschied zaehlt:
* Es soll nicht so aussehen, als waere geprueft worden.
*/
export function betragPasstZumVertrag(
betragCent: bigint,
beitragCent: bigint | null | undefined,
rhythmus: ZahlRhythmus | null | undefined,
): boolean | null {
if (beitragCent === null || beitragCent === undefined || beitragCent <= 0n) return null
// Zu einer Einmalzahlung gibt es keine wiederkehrende Rate, gegen die
// sich vergleichen liesse.
if (!rhythmus || rhythmus === 'EINMALIG') return null
const betrag = betragCent < 0n ? -betragCent : betragCent
const abweichung = betrag > beitragCent ? betrag - beitragCent : beitragCent - betrag
return abweichung * 100n <= beitragCent * BETRAGS_TOLERANZ_PROZENT
}
/**
* Ist die Buchung durch einen Vertrag belegt?
*
* Bei einem Dauerschuldverhaeltnis ist der Vertrag der Beleg - fuer zwoelf
* gleiche Lastschriften gibt es keine zwoelf Quittungen, sondern eine
* Police.
*
* Die Anerkennung faellt weg, sobald der Betrag deutlich abweicht. Sonst
* waere die Zuordnung eine Hintertuer, durch die auch eine verdreifachte
* Praemie unbemerkt als "belegt" durchginge.
*/
export function belegDurchVertrag(buchung: BuchungEingabe): boolean {
if (buchung.belegId) return false
if (!buchung.vertragId) return false
return (
betragPasstZumVertrag(
buchung.betragCent,
buchung.vertragBeitragCent,
buchung.vertragRhythmus,
) !== false
)
}
/**
* Zugeordnet, aber der Betrag passt nicht - das will jemand ansehen.
*
* Kein Fehler und kein Vorwurf: Meistens ist es eine Beitragsanpassung, die
* am Vertrag noch nicht nachgetragen wurde. Genau deshalb steht sie hier
* und nicht wortlos unter "Beleg fehlt".
*/
export function betragPruefen(buchung: BuchungEingabe): boolean {
if (buchung.belegId) return false
if (!buchung.vertragId) return false
return (
betragPasstZumVertrag(
buchung.betragCent,
buchung.vertragBeitragCent,
buchung.vertragRhythmus,
) === false
)
}
/**
* Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik,
* nicht fuer den Aktenordner - aber sie beantwortet die Frage des
* Rechtspflegers "und wo ist der Beleg?" schon vorab.
*/
export function belegFehlt(buchung: BuchungEingabe): boolean {
return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim()
if (buchung.belegId) return false
if (belegDurchVertrag(buchung)) return false
return !(buchung.belegFehltBegruendung ?? '').trim()
}
export function belegBegruendet(buchung: BuchungEingabe): boolean {
return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== ''
if (buchung.belegId) return false
if (belegDurchVertrag(buchung)) return false
return (buchung.belegFehltBegruendung ?? '').trim() !== ''
}
export type Belegquote = {
gesamt: number
mitBeleg: number
/**
* Dauerzahlung zu einem Vertrag - eigene Kategorie, NICHT eingerechnet in
* "begruendet". Ein Vertrag belegt, eine Begruendung erklaert nur; das
* zusammenzuwerfen nimmt der Zahl ihre Aussage gegenueber dem
* Rechtspfleger.
*/
durchVertrag: number
begruendet: number
ohneNachweis: number
/** Zugeordnet, aber der Betrag weicht ab. Teilmenge von ohneNachweis. */
zuPruefen: number
/** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */
quoteProzent: number
}
@@ -79,11 +198,13 @@ export type Belegquote = {
export function belegquote(buchungen: BuchungEingabe[]): Belegquote {
const gesamt = buchungen.length
const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length
const durchVertrag = buchungen.filter((b) => belegDurchVertrag(b)).length
const begruendet = buchungen.filter((b) => belegBegruendet(b)).length
const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length
const zuPruefen = buchungen.filter((b) => betragPruefen(b)).length
const quoteProzent =
gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, begruendet, ohneNachweis, quoteProzent }
gesamt === 0 ? 100 : Math.round(((mitBeleg + durchVertrag + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, durchVertrag, begruendet, ohneNachweis, zuPruefen, quoteProzent }
}
/**
+15
View File
@@ -3,7 +3,9 @@ import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@
import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import {
ausgabenGruppiert,
belegDurchVertrag,
belegFehlt,
betragPruefen,
einnahmenGruppiert,
istVermoegensbuchung,
belegquote,
@@ -158,6 +160,16 @@ export type Belegzeile = {
belegId: string | null
belegTitel: string | null
belegFehltBegruendung: string | null
/**
* Dauerzahlung zu einem Vertrag. Steht im Bericht anstelle von
* "Beleg fehlt" - die Luecke wird damit nicht versteckt, sondern
* erklaert: Wer nachpruefen will, weiss, welches Papier er aus dem
* Ordner holt.
*/
vertragBezeichnung: string | null
vertragsnummer: string | null
/** Zugeordnet, aber der Betrag weicht ab - im Bericht ausgewiesen. */
betragZuPruefen: boolean
fehlt: boolean
}
@@ -179,6 +191,9 @@ export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] {
belegId: buchung.belegId,
belegTitel: buchung.belegTitel ?? null,
belegFehltBegruendung: buchung.belegFehltBegruendung,
vertragBezeichnung: belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null,
vertragsnummer: belegDurchVertrag(buchung) ? (buchung.vertragsnummer ?? null) : null,
betragZuPruefen: betragPruefen(buchung),
fehlt: belegFehlt(buchung),
}))
}
+7 -1
View File
@@ -40,7 +40,13 @@ export async function wiederkehrendLaden(betreuteId: string): Promise<Wiederkehr
betrifftBetreute: true,
...nichtGeloescht,
},
select: { id: true, datum: true, betragCent: true, gegenstelle: true },
select: {
id: true,
datum: true,
betragCent: true,
gegenstelle: true,
vertragId: true,
},
orderBy: { datum: 'asc' },
}),
db.vertrag.findMany({
+34
View File
@@ -32,6 +32,8 @@ export type ZahlungEingabe = {
datum: Date
betragCent: bigint
gegenstelle: string | null
/** Haengt die Buchung schon an einem Vertrag? (Issue #11) */
vertragId?: string | null
}
/** Ein hinterlegter Vertrag - zum Abgleich, ob die Zahlung schon erklaert ist. */
@@ -94,6 +96,14 @@ export type Zahlungsgruppe = {
vertragId: string | null
vertragBezeichnung: string | null
buchungIds: string[]
/**
* Wie viele Buchungen der Gruppe haengen bereits an einem Vertrag?
* Grundlage fuer die Sammelzuordnung: Nur der Rest muss noch verknuepft
* werden.
*/
bereitsZugeordnet: number
/** Buchungen der Gruppe ohne Vertragsbezug. */
offeneBuchungIds: string[]
}
// -----------------------------------------------------------------------------
@@ -366,6 +376,8 @@ export function gruppieren(
vertragId: vertrag?.id ?? null,
vertragBezeichnung: vertrag?.bezeichnung ?? null,
buchungIds: sortiert.map((p) => p.id),
bereitsZugeordnet: posten.filter((p) => Boolean(p.vertragId)).length,
offeneBuchungIds: sortiert.filter((p) => !p.vertragId).map((p) => p.id),
})
}
@@ -506,6 +518,28 @@ export function alsZahlRhythmus(rhythmus: Rhythmus): string | null {
}
}
/**
* Sucht den Vertrag, zu dem eine Gegenstelle EINDEUTIG passt.
*
* Gedacht fuer die automatische Zuordnung beim Import. Die Betonung liegt
* auf eindeutig: Passen zwei Vertraege (Hausrat und Haftpflicht bei
* derselben Gesellschaft), wird nichts zugeordnet. Eine falsche Zuordnung
* waere schlimmer als gar keine - sie behauptet einen Beleg, den es nicht
* gibt, und niemand sieht noch einmal hin.
*
* Gibt null zurueck, wenn nichts oder mehr als eines passt.
*/
export function eindeutigerVertrag(
gegenstelle: string | null | undefined,
vertraege: VertragAbgleich[],
): VertragAbgleich | null {
const schluessel = normalisieren(gegenstelle)
if (!schluessel) return null
const treffer = vertraege.filter((vertrag) => normalisieren(vertrag.gesellschaft) === schluessel)
return treffer.length === 1 ? treffer[0]! : null
}
/** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */
export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number {
return gruppen.filter((gruppe) => gruppe.vertragId === null).length
+113
View File
@@ -0,0 +1,113 @@
/**
* Die Arten von Schreiben, die die Anwendung erzeugen kann.
*
* Rein und ohne Datenbank - der Entwurfstext entsteht anderswo
* (src/lib/schreiben/entwurf.ts), hier steht nur, was eine Art ausmacht.
*
* Warum ueberhaupt eine Aufzaehlung? Damit die Wiedervorlagefrist,
* die Beschriftung und der Dokumenttitel an EINER Stelle stehen. Vorher
* lagen sie verstreut: 28 Tage in rueckkaufswert.ts, 42 in
* sachstandsanfrage.ts, die Beschriftungen jeweils in der Seite.
*/
export type SchreibenArt = 'RUECKKAUFSWERT' | 'KUENDIGUNG' | 'SACHSTAND' | 'FREI'
export const SCHREIBEN_ARTEN: readonly SchreibenArt[] = [
'RUECKKAUFSWERT',
'KUENDIGUNG',
'SACHSTAND',
'FREI',
]
export type SchreibenEigenschaften = {
beschriftung: string
/**
* Vorgeschlagene Wiedervorlage in Tagen.
*
* null heisst: keine vorschlagen. Bei einem freien Schreiben weiss
* niemand, ob und wann eine Antwort kommt - eine erfundene Frist waere
* schlechter als keine.
*/
wiedervorlageTage: number | null
/** Woran haengt das Schreiben? Bestimmt, was die Seite laden muss. */
bezug: 'VERTRAG' | 'VORGANG' | 'KEINER'
/** Kurze Erklaerung, warum die Frist so lang ist. */
fristBegruendung: string | null
}
export const SCHREIBEN_EIGENSCHAFTEN: Record<SchreibenArt, SchreibenEigenschaften> = {
RUECKKAUFSWERT: {
beschriftung: 'Auskunft über Rückkaufswert',
// Dieselbe Zahl wie RUECKKAUFSWERT_WIEDERVORLAGE_TAGE - dort steht sie
// fuer die Anzeige am Vertrag, hier fuer die erzeugte Frist. Ein Test
// haelt beide zusammen.
wiedervorlageTage: 28,
bezug: 'VERTRAG',
fristBegruendung:
'Vier Wochen sind bei Versicherern die übliche Bearbeitungszeit. Kommt bis dahin ' +
'nichts, lohnt das Nachfassen.',
},
KUENDIGUNG: {
beschriftung: 'Kündigung',
// Kuerzer als beim Rueckkaufswert: Eine Kuendigung muss bestaetigt
// werden, und ohne Bestaetigung ist nicht belegt, dass sie ankam.
wiedervorlageTage: 21,
bezug: 'VERTRAG',
fristBegruendung:
'Drei Wochen. Eine Kündigung ohne schriftliche Bestätigung ist nicht nachgewiesen — ' +
'und wenn sie untergegangen ist, läuft die Frist weiter.',
},
SACHSTAND: {
beschriftung: 'Sachstandsanfrage',
wiedervorlageTage: 42,
bezug: 'VORGANG',
fristBegruendung:
'Sechs Wochen. Behörden brauchen länger als Versicherer; früher nachzufassen bringt ' +
'meist nur die Antwort, dass es noch dauert.',
},
FREI: {
beschriftung: 'Freies Schreiben',
wiedervorlageTage: null,
bezug: 'KEINER',
fristBegruendung: null,
},
}
/** Ist der Wert eine bekannte Art? Fuer Parameter aus der Adresszeile. */
export function istSchreibenArt(wert: string | null | undefined): wert is SchreibenArt {
return typeof wert === 'string' && (SCHREIBEN_ARTEN as readonly string[]).includes(wert)
}
/**
* Dateiname fuer die Word-Datei und die Ablage.
*
* Sprechend, sortierbar, ohne Zeichen, die ein Dateisystem nicht mag:
* "2026-08-23_Kuendigung_Allianz.docx"
*/
export function dateinameFuerSchreiben(
art: SchreibenArt,
empfaenger: string | null,
datum: Date,
endung: 'docx' | 'pdf',
): string {
const tag = datum.toISOString().slice(0, 10)
const sauber = (text: string) =>
text
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae')
.replace(/Ö/g, 'Oe')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
const teile = [tag, sauber(SCHREIBEN_EIGENSCHAFTEN[art].beschriftung)]
const ziel = empfaenger ? sauber(empfaenger) : ''
if (ziel) teile.push(ziel)
return `${teile.join('_')}.${endung}`
}
+118
View File
@@ -0,0 +1,118 @@
import 'server-only'
import {
AlignmentType,
Document,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
/**
* Ein Brief als Word-Datei.
*
* Getrennt von src/lib/berichte/docx.ts, und das ist Absicht. Der dortige
* Satz baut einen BERICHT: Titel, Kopfzeilen mit betreuter Person,
* Aktenzeichen und Berichtszeitraum, am Ende ein Unterschriftenblock. Ein
* Brief braucht etwas anderes - Absender, Anschriftenfeld, Datumszeile
* rechts, Betreff, Anrede, Grussformel. Beides in eine Funktion zu zwingen
* haette beide Ausgaben schlechter gemacht.
*
* Warum ueberhaupt .docx und nicht nur PDF? Weil jeder Fall eine
* Besonderheit hat, die in den Brief gehoert. Wer das Schreiben vor dem
* Absenden noch anfassen will, braucht eine Datei, die sich anfassen
* laesst. Die Fassung fuer die Akte ist ein PDF - siehe
* src/lib/schreiben/pdf.tsx.
*
* Das Layout folgt DIN 5008 lose: Anschriftenfeld oben links, Datum
* rechts, Betreff fett. Nicht auf den Millimeter - der Brief geht in einen
* gewoehnlichen Umschlag, nicht in ein Fensterkuvert der Behoerde.
*/
const SCHRIFT = 'Arial'
const GROESSE = 11
/**
* Der Entwurfstext kommt als eine Zeichenkette mit Zeilenumbruechen.
*
* Er ist bereits vollstaendig gesetzt: Absender, Anschrift, Datum,
* Betreff, Anrede, Text, Gruss. Diese Funktion formatiert ihn nur - sie
* denkt sich nichts dazu und laesst nichts weg. Was der Benutzer im
* Browser gesehen hat, steht auch in der Datei.
*/
function absatz(
text: string,
optionen: { fett?: boolean; rechts?: boolean; abstandNach?: number } = {},
): Paragraph {
return new Paragraph({
alignment: optionen.rechts ? AlignmentType.RIGHT : AlignmentType.LEFT,
spacing: { after: optionen.abstandNach ?? 0, line: 276 },
children: [
new TextRun({
text,
bold: optionen.fett ?? false,
size: GROESSE * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
}),
],
})
}
/**
* Erkennt die Betreffzeile, damit sie fett gesetzt werden kann.
*
* Die erzeugten Schreiben beginnen sie mit "Betreff:" - siehe
* betreffZeile() in src/lib/vertraege/anschreiben.ts. Steht dort einmal
* etwas anderes, wird die Zeile eben normal gesetzt; ein Brief mit
* unfettem Betreff ist immer noch ein brauchbarer Brief.
*/
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
/**
* Die Datumszeile steht rechts, wie im deutschen Geschaeftsbrief.
* Erkennbar daran, dass sie mit einem Ort und Komma oder direkt mit einem
* Datum beginnt und sonst nichts enthaelt.
*/
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
export function briefAlsDocx(text: string): Document {
const zeilen = text.split('\n')
const inhalt: Paragraph[] = zeilen.map((zeile) => {
if (zeile.trim() === '') return absatz('', { abstandNach: 120 })
if (istBetreff(zeile)) return absatz(zeile, { fett: true, abstandNach: 120 })
if (istDatumszeile(zeile)) return absatz(zeile, { rechts: true })
return absatz(zeile)
})
return new Document({
creator: 'Betreuungs-Cockpit',
description: 'Schreiben aus der Betreuungsakte',
title: 'Schreiben',
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
// Links etwas mehr: Da wird gelocht und abgeheftet.
left: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
},
},
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function briefDocxErzeugen(text: string): Promise<Buffer> {
return Packer.toBuffer(briefAlsDocx(text))
}
+242
View File
@@ -0,0 +1,242 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import {
absenderBlock,
anschriftBlock,
betreffZeile,
bestellungsAbsatz,
datumsZeile,
grussformel,
type Briefkopf,
type Empfaenger,
} from '@/lib/vertraege/anschreiben'
import { rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { SCHREIBEN_EIGENSCHAFTEN, type SchreibenArt } from '@/lib/schreiben/arten'
/**
* Baut den Entwurf eines Schreibens - Text, Betreff, Empfaenger.
*
* Hier laufen die vorhandenen Erzeuger zusammen (Rueckkaufswert,
* Kuendigung, Sachstandsanfrage). Sie bleiben unveraendert: Ihr Wortlaut
* ist getestet und in der Praxis erprobt, und ihn fuer eine neue
* Ausgabeform umzuschreiben waere ein Risiko ohne Gewinn.
*
* Was hier dazukommt, ist der vierte Fall: das freie Schreiben an eine
* Stelle, zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung
* ist das der Normalfall - man fragt ja gerade an, um zu erfahren, ob dort
* ueberhaupt etwas besteht.
*/
export type SchreibenEntwurf = {
art: SchreibenArt
betreff: string
/** Vollstaendiger Brieftext, bearbeitbar. */
text: string
empfaengerName: string
empfaengerAnschrift: string
/** Vorgeschlagenes Datum der Wiedervorlage, ISO-Tag oder leer. */
wiedervorlageAm: string
/** Woran haengt das Schreiben? */
vertragId: string | null
vorgangId: string | null
/** Was vor dem Abschicken zu klaeren ist - aus dem Kuendigungsassistenten. */
warnungen: string[]
}
function tagAlsIsoText(datum: Date): string {
return datum.toISOString().slice(0, 10)
}
function wiedervorlageVorschlag(art: SchreibenArt, heute: Date): string {
const tage = SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage
if (tage === null) return ''
const ziel = new Date(heute)
ziel.setUTCDate(ziel.getUTCDate() + tage)
return tagAlsIsoText(ziel)
}
/**
* Der Rumpf eines freien Schreibens.
*
* Bewusst kurz und mit sichtbaren Luecken in eckigen Klammern. Ein
* vorformulierter Text, der fast passt, wird abgeschickt, ohne gelesen zu
* werden; eine offensichtliche Luecke nicht.
*
* Der Bestellungsabsatz steht drin und nicht zur Wahl: Ohne den Nachweis
* der Vertretungsmacht kommt die Auskunft nicht, und das faellt erst nach
* vier Wochen auf.
*/
function freierEntwurf(kopf: Briefkopf, empfaenger: Empfaenger, heute: Date): string {
return [
absenderBlock(kopf),
'',
anschriftBlock(empfaenger),
'',
datumsZeile(kopf.betreuerOrt, heute),
'',
betreffZeile('[Betreff eintragen]', null, kopf.betreuteName, kopf.betreuteGeburtsdatum),
'',
'Sehr geehrte Damen und Herren,',
'',
bestellungsAbsatz(kopf),
'',
'[Hier steht dein Anliegen.]',
'',
'Für Rückfragen stehe ich gerne zur Verfügung.',
'',
grussformel(kopf.betreuerName),
'',
'Anlage: Kopie der Bestellungsurkunde',
].join('\n')
}
/** Zieht die Betreffzeile aus einem fertigen Brieftext. */
export function betreffAusText(text: string): string {
const zeile = text.split('\n').find((z) => /^Betreff:/i.test(z.trim()))
return zeile ? zeile.replace(/^Betreff:\s*/i, '').trim() : ''
}
export type EntwurfEingabe = {
betreuteId: string
benutzerName: string
art: SchreibenArt
vertragId?: string | null
vorgangId?: string | null
/** Fuer den Test ueberschreibbar. */
heute?: Date
}
export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEntwurf> {
const heute = eingabe.heute ?? heuteAlsTag()
const betreute = await db.betreute.findFirst({
where: { id: eingabe.betreuteId, ...nichtGeloescht },
})
if (!betreute) throw new Error('Die betreute Person gibt es nicht (mehr).')
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName },
})
const gemeinsam = {
art: eingabe.art,
wiedervorlageAm: wiedervorlageVorschlag(eingabe.art, heute),
vertragId: eingabe.vertragId ?? null,
vorgangId: eingabe.vorgangId ?? null,
warnungen: [] as string[],
}
// --- Vertragsbezogene Schreiben -------------------------------------------
if (eingabe.art === 'RUECKKAUFSWERT' || eingabe.art === 'KUENDIGUNG') {
if (!eingabe.vertragId) throw new Error('Für dieses Schreiben fehlt der Vertrag.')
const vertrag = await db.vertrag.findFirst({
where: { id: eingabe.vertragId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
})
if (!vertrag) throw new Error('Diesen Vertrag gibt es nicht (mehr).')
const offen = oeffnen('Vertrag', vertrag)
const empfaenger: Empfaenger = { gesellschaft: vertrag.gesellschaft, anschrift: null }
const bezeichnung = `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`
if (eingabe.art === 'RUECKKAUFSWERT') {
const text = rueckkaufswertAnschreiben({
kopf,
empfaenger,
vertragsbezeichnung: bezeichnung,
vertragsnummer: offen.vertragsnummer,
stichtag: heute,
heute,
})
return {
...gemeinsam,
text,
betreff: betreffAusText(text),
empfaengerName: vertrag.gesellschaft ?? '',
empfaengerAnschrift: '',
}
}
const paket = kuendigungsassistent({
kopf,
empfaenger,
vertragsbezeichnung: bezeichnung,
vertragsnummer: offen.vertragsnummer,
art: vertrag.art,
laufzeit: {
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
},
kuendigungZum: vertrag.kuendigungZum,
heute,
})
return {
...gemeinsam,
text: paket.schreiben,
betreff: betreffAusText(paket.schreiben),
empfaengerName: vertrag.gesellschaft ?? '',
empfaengerAnschrift: '',
warnungen: paket.warnungen,
}
}
// --- Sachstandsanfrage zum Vorgang ----------------------------------------
if (eingabe.art === 'SACHSTAND') {
if (!eingabe.vorgangId) throw new Error('Für dieses Schreiben fehlt der Vorgang.')
const vorgang = await db.vorgang.findFirst({
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
include: {
kontakte: { select: { name: true, organisation: true }, take: 1 },
},
})
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
const anfrage = sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsdatum: betreute.geburtsdatum,
},
betreuer: {
name: eingabe.benutzerName,
aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht,
},
heute,
})
return {
...gemeinsam,
text: anfrage.text,
betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '',
empfaengerAnschrift: '',
}
}
// --- Freies Schreiben ------------------------------------------------------
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
return {
...gemeinsam,
text,
betreff: '',
empfaengerName: '',
empfaengerAnschrift: '',
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'server-only'
import * as React from 'react'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import { fuerStandardschrift, pdfErzeugen } from '@/lib/pdf/erzeugen'
/**
* Das Schreiben als PDF - die Fassung, die in die Akte geht.
*
* Warum PDF und nicht die Word-Datei?
*
* Erstens der Ablageschutz: src/lib/speicher/dateityp.ts laesst nur PDF und
* Bilder in den Objektspeicher, und zwar nach den Magic Bytes, nicht nach
* Endung oder gemeldetem Typ. Eine .docx ist ein ZIP-Archiv; sie
* durchzulassen hiesse, jedes ZIP durchzulassen. Diese Pruefung ist eine
* bewusste Zusage der Anwendung, und sie fuer eine Bequemlichkeit
* aufzuweichen waere der falsche Handel.
*
* Zweitens die Sache selbst: In der Akte liegt der Nachweis, was
* hinausgegangen ist. Dafuer ist ein Format, das sich nicht mehr aendert,
* das richtige. Die .docx ist die Arbeitsfassung zum Ausdrucken und
* Unterschreiben - sie liegt beim Benutzer, nicht in der Akte.
*
* Zu beachten: Abgelegt wird der Text, wie er beim Ablegen im Browser
* stand. Wer die Word-Datei danach noch aendert, hat in der Akte eine
* andere Fassung als im Briefkasten. Die Oberflaeche sagt das dazu.
*/
const stile = StyleSheet.create({
seite: {
paddingTop: 70,
paddingBottom: 56,
paddingLeft: 71, // 25 mm - da wird gelocht
paddingRight: 57,
fontSize: 10.5,
lineHeight: 1.45,
fontFamily: 'Helvetica',
color: '#111111',
},
zeile: { marginBottom: 0 },
leer: { height: 9 },
betreff: { fontFamily: 'Helvetica-Bold', marginBottom: 4 },
datum: { textAlign: 'right' },
fuss: {
position: 'absolute',
bottom: 28,
left: 71,
right: 57,
fontSize: 7.5,
color: '#777777',
textAlign: 'center',
},
})
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
export function Briefdokument({
text,
erzeugtAm,
}: {
text: string
erzeugtAm: Date
}): React.JSX.Element {
const zeilen = text.split('\n')
return (
<Document>
<Page size="A4" style={stile.seite}>
{zeilen.map((zeile, i) => {
if (zeile.trim() === '') return <View key={i} style={stile.leer} />
const stil = istBetreff(zeile)
? [stile.zeile, stile.betreff]
: istDatumszeile(zeile)
? [stile.zeile, stile.datum]
: stile.zeile
return (
<Text key={i} style={stil}>
{fuerStandardschrift(zeile)}
</Text>
)
})}
<Text style={stile.fuss} fixed>
{fuerStandardschrift(
`Aus dem Betreuungs-Cockpit erzeugt am ${erzeugtAm.toLocaleDateString('de-DE')}. ` +
'Diese Fassung liegt in der Akte.',
)}
</Text>
</Page>
</Document>
)
}
/** Fertiges PDF als Buffer, fuer die Ablage im Objektspeicher. */
export async function briefPdfErzeugen(text: string, erzeugtAm: Date): Promise<Buffer> {
return pdfErzeugen(<Briefdokument text={text} erzeugtAm={erzeugtAm} />)
}
+1
View File
@@ -229,6 +229,7 @@ export const buchungSchema = z
verwendungszweck: optionalerText(500),
belegId: optionaleId,
belegFehltBegruendung: optionalerText(300),
vertragId: optionaleId,
betrifftBetreute: schalter,
vermoegenswirksam: schalter,
})
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.2.0'
export const VERSION = '1.4.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+95
View File
@@ -11,6 +11,7 @@ import { blindindex } from '@/lib/crypto/hash'
import { alsTag } from '@/lib/finanzen/datum'
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
import { camtZuBuchungen } from '@/lib/finanzen/camt'
import { eindeutigerVertrag } from '@/lib/finanzen/wiederkehrend'
import {
buchungSchema,
fehlerAuflisten,
@@ -349,6 +350,10 @@ export async function buchungSpeichern(
kategorie: werte.kategorie,
gegenstelle: werte.gegenstelle,
belegId: werte.belegId,
vertragId: werte.vertragId,
// Von Hand gesetzt heisst: nicht mehr "automatisch". Wer die
// Zuordnung anfasst, uebernimmt sie.
vertragAutomatisch: false,
belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung,
betrifftBetreute: werte.betrifftBetreute,
vermoegenswirksam: werte.vermoegenswirksam,
@@ -515,8 +520,18 @@ export async function importAusfuehren(
const anzulegen = zeilen.filter((z) => werte.dublettenUebernehmen || !z.dublette)
// Vertraege einmal holen, nicht je Zeile. Zugeordnet wird nur, wo die
// Gegenstelle zu genau EINEM Vertrag passt - bei Mehrdeutigkeit
// geschieht nichts.
const vertraege = await db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
})
let angelegt = 0
let zugeordnet = 0
for (const zeile of anzulegen) {
const passenderVertrag = eindeutigerVertrag(zeile.gegenstelle, vertraege)
try {
await db.buchung.create({
data: {
@@ -530,10 +545,17 @@ export async function importAusfuehren(
gegenstelle: zeile.gegenstelle,
betrifftBetreute: werte.betrifftBetreute,
importReferenz: werte.dublettenUebernehmen && zeile.dublette ? null : zeile.importReferenz,
// Nur bei Ausgaben: Eine Gutschrift der Versicherung ist keine
// Praemienzahlung und darf nicht als "durch Vertrag belegt"
// gelten.
...(zeile.richtung === 'AUSGABE' && passenderVertrag
? { vertragId: passenderVertrag.id, vertragAutomatisch: true }
: {}),
...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }),
},
})
angelegt++
if (zeile.richtung === 'AUSGABE' && passenderVertrag) zugeordnet++
} catch (ausnahme) {
// Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab,
// die zwischen Lesen und Schreiben dazugekommen sind.
@@ -563,6 +585,13 @@ export async function importAusfuehren(
? [`${gelesen.fehler.length} Zeile(n) konnten nicht gelesen werden:`]
: []),
...gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
...(zugeordnet > 0
? [
`${zugeordnet} Buchung(en) wurden automatisch einem Vertrag zugeordnet und gelten ` +
'damit als belegt. Sie sind in der Liste als automatisch zugeordnet erkennbar – ' +
'bitte einmal durchsehen.',
]
: []),
'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' +
'Bitte die Kategorien nachtragen – danach stimmen die Berichte.',
]
@@ -924,3 +953,69 @@ export async function zahlungsgruppeZurueckholen(
revalidatePath('/heute')
return { erfolg: 'Die Gruppe steht wieder in der Liste.' }
}
/**
* Ordnet mehrere Buchungen auf einmal einem Vertrag zu.
*
* Der Weg dorthin fuehrt ueber "Wiederkehrende Zahlungen": Dort steht eine
* Gruppe gleichartiger Abbuchungen beisammen, und wenn dazu ein Vertrag in
* der Akte liegt, sind es genau die Buchungen, die er belegt. Sie einzeln
* anzuklicken waere die Arbeit, die dieses Issue abschaffen soll.
*
* Die IDs kommen aus dem Formular und werden NICHT geglaubt: Geschrieben
* wird nur, was zu dieser betreuten Person gehoert.
*/
export async function zahlungenVertragZuordnen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const vertragId = String(daten.get('vertragId') ?? '')
const buchungIds = String(daten.get('buchungIds') ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (!vertragId || buchungIds.length === 0) {
return { fehler: 'Es ist nichts zum Zuordnen ausgewählt.' }
}
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const vertrag = await db.vertrag.findFirst({
where: { id: vertragId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
if (!vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
// betreuteId im WHERE ist der eigentliche Schutz: Eine fremde
// Buchungs-ID trifft dann auf null Zeilen.
const { count } = await db.buchung.updateMany({
where: { id: { in: buchungIds }, betreuteId, ...nichtGeloescht },
data: { vertragId: vertrag.id, vertragAutomatisch: false },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Buchung',
bezeichnung: `${count} Buchung(en) als Dauerzahlung zu „${vertrag.bezeichnung}“`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: `${count} Buchung(en) gelten jetzt als durch den Vertrag belegt.` }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Sammelzuordnung fehlgeschlagen:', ausnahme)
return { fehler: 'Die Zuordnung konnte nicht gespeichert werden.' }
}
}
+291
View File
@@ -0,0 +1,291 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
import {
SCHREIBEN_EIGENSCHAFTEN,
dateinameFuerSchreiben,
istSchreibenArt,
type SchreibenArt,
} from '@/lib/schreiben/arten'
import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
/**
* Server Actions rund um erzeugte Schreiben.
*
* Der Kern ist "In die Akte legen". Bisher endete ein Schreiben beim Text
* zum Kopieren; alles danach - ausdrucken, abschicken, als Dokument
* nachtragen, Wiedervorlage setzen - war Handarbeit, und der letzte
* Schritt wurde am haeufigsten vergessen. Dann fehlt der Nachweis genau
* dort, wo er gebraucht wird: im Bericht ans Gericht.
*
* Hier passiert alles in einem Zug, und zwar so, dass ein Abbruch nichts
* Halbes hinterlaesst.
*/
export type SchreibenZustand = {
fehler?: string
erfolg?: string
/** Kurze Aufzaehlung dessen, was angelegt wurde. */
meldung?: string[]
}
function zugriffsfehler(ausnahme: unknown): SchreibenZustand | null {
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
return null
}
const ablegenSchema = z.object({
betreuteId: z.string().min(1),
art: z.string().refine(istSchreibenArt, 'Unbekannte Art von Schreiben.'),
betreff: z.string().trim().min(1, 'Bitte einen Betreff eintragen.').max(200),
text: z.string().trim().min(30, 'Der Text ist zu kurz für ein Schreiben.').max(50_000),
empfaengerName: z.string().trim().max(200).default(''),
versandAm: z.string().trim().default(''),
wiedervorlageAm: z.string().trim().default(''),
vertragId: z.string().trim().default(''),
vorgangId: z.string().trim().default(''),
})
/**
* Legt das Schreiben als Ausgangsdokument in die Akte und setzt die
* Wiedervorlage.
*
* Reihenfolge mit Absicht: Erst die Datei in den Objektspeicher, dann der
* Datensatz. Bricht das Ablegen ab, gibt es keinen Dokumentdatensatz, der
* auf eine Datei zeigt, die es nicht gibt. Umgekehrt bliebe im
* schlimmsten Fall eine verwaiste Datei liegen - die kostet Platz, aber
* sie luegt niemanden an.
*/
export async function schreibenAblegen(
_zustand: SchreibenZustand,
daten: FormData,
): Promise<SchreibenZustand> {
const gelesen = ablegenSchema.safeParse({
betreuteId: daten.get('betreuteId'),
art: daten.get('art'),
betreff: daten.get('betreff'),
text: daten.get('text'),
empfaengerName: daten.get('empfaengerName') ?? '',
versandAm: daten.get('versandAm') ?? '',
wiedervorlageAm: daten.get('wiedervorlageAm') ?? '',
vertragId: daten.get('vertragId') ?? '',
vorgangId: daten.get('vorgangId') ?? '',
})
if (!gelesen.success) {
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
}
const werte = gelesen.data
const art = werte.art as SchreibenArt
const betreuteId = werte.betreuteId
try {
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
const versandAm = tagAusText(werte.versandAm) ?? heuteAlsTag()
// --- Bezuege pruefen, bevor irgendetwas geschrieben wird ---------------
const vertrag = werte.vertragId
? await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
: null
if (werte.vertragId && !vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
const vorgang = werte.vorgangId
? await db.vorgang.findFirst({
where: { id: werte.vorgangId, betreuteId, ...nichtGeloescht },
select: { id: true, titel: true },
})
: null
if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
// --- Datei zuerst -------------------------------------------------------
const pdf = await briefPdfErzeugen(werte.text, versandAm)
const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf')
const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf')
// --- Dann der Datensatz -------------------------------------------------
const dokument = await db.dokument.create({
data: {
betreuteId,
titel: werte.betreff.slice(0, 200),
art: 'SCHREIBEN',
richtung: 'AUSGANG',
schreibenVom: versandAm,
versandAm,
empfaenger: werte.empfaengerName || null,
objektSchluessel: ablage.schluessel,
dateiname: ablage.dateiname,
mimeTyp: ablage.typ.mimeTyp,
groesseBytes: ablage.groesseBytes,
pruefsumme: ablage.pruefsumme,
// Was wir selbst gesetzt haben, muss nicht erkannt werden.
ocrStatus: 'nicht_verfuegbar',
// Ein eigenes Schreiben gehoert nicht in den Posteingang - es ist
// ja nichts angekommen, das noch zuzuordnen waere.
posteingangStatus: 'ZUGEORDNET',
...(vertrag || vorgang
? {
verknuepfungen: {
create: [
{
betreuteId,
...(vertrag ? { vertragId: vertrag.id } : {}),
...(vorgang ? { vorgangId: vorgang.id } : {}),
},
],
},
}
: {}),
},
select: { id: true, titel: true },
})
const meldung = [`Als Ausgangsdokument abgelegt: „${dokument.titel}“.`]
// --- Wiedervorlage ------------------------------------------------------
const wiedervorlage = tagAusText(werte.wiedervorlageAm)
if (wiedervorlage) {
const frist = await db.frist.create({
data: {
betreuteId,
vorgangId: vorgang?.id ?? null,
titel: `Antwort abwarten: ${werte.betreff}`.slice(0, 200),
beschreibung:
`Am ${versandAm.toLocaleDateString('de-DE')} verschickt` +
(werte.empfaengerName ? ` an ${werte.empfaengerName}` : '') +
'. Kommt bis zu diesem Tag nichts, nachfassen.',
datum: wiedervorlage,
typ: 'WIEDERVORLAGE',
verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] },
},
select: { id: true },
})
meldung.push(
`Wiedervorlage zum ${wiedervorlage.toLocaleDateString('de-DE')} angelegt.`,
)
void frist
} else if (SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage !== null) {
meldung.push('Ohne Wiedervorlage abgelegt — es erinnert dich also nichts daran.')
}
// --- Beim Vorgang festhalten, dass wir gerade geschrieben haben --------
if (vorgang) {
await db.vorgang.update({
where: { id: vorgang.id },
data: { letzterKontaktAm: versandAm },
})
}
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Dokument',
entitaetId: dokument.id,
bezeichnung: `Schreiben ausgegangen: ${dokument.titel}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/dokumente')
revalidatePath('/fristen')
revalidatePath('/heute')
if (vertrag) revalidatePath(`/vertraege/${vertrag.id}`)
if (vorgang) revalidatePath(`/vorgaenge/${vorgang.id}`)
return { erfolg: 'Das Schreiben liegt in der Akte.', meldung }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
}
}
const kontaktSchema = z.object({
betreuteId: z.string().min(1),
name: z.string().trim().min(1, 'Bitte einen Namen eintragen.').max(200),
anschrift: z.string().trim().max(500).default(''),
})
/**
* Legt aus einer frei eingegebenen Anschrift einen Kontakt an.
*
* Damit man dieselbe Stelle beim naechsten Mal aus der Liste waehlen kann,
* statt sie erneut abzutippen. Genau so waechst das Adressbuch einer
* Betreuung: nicht durch Pflege, sondern nebenbei beim Schreiben.
*/
export async function kontaktAusAnschrift(
_zustand: SchreibenZustand,
daten: FormData,
): Promise<SchreibenZustand> {
const gelesen = kontaktSchema.safeParse({
betreuteId: daten.get('betreuteId'),
name: daten.get('empfaengerName'),
anschrift: daten.get('empfaengerAnschrift') ?? '',
})
if (!gelesen.success) {
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
}
const { betreuteId, name, anschrift } = gelesen.data
try {
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
// Die freie Anschrift kommt als Block; die erste Zeile ist die Strasse,
// die zweite PLZ und Ort. Mehr wird nicht geraten - was nicht sicher
// zuzuordnen ist, landet in den Notizen.
const zeilen = anschrift
.split('\n')
.map((z) => z.trim())
.filter(Boolean)
const strasse = zeilen[0] ?? null
const ortszeile = zeilen[1] ?? ''
const treffer = ortszeile.match(/^(\d{5})\s+(.+)$/)
const kontakt = await db.kontakt.create({
data: {
betreuteId,
name: name.slice(0, 200),
organisation: name.slice(0, 200),
rolle: 'SONSTIGES',
strasse,
plz: treffer?.[1] ?? null,
ort: treffer?.[2] ?? (treffer ? null : ortszeile || null),
},
select: { id: true, name: true },
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Kontakt',
entitaetId: kontakt.id,
bezeichnung: kontakt.name,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/kontakte')
return { erfolg: `„${kontakt.name}“ steht jetzt in den Kontakten.` }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[schreiben] Kontakt konnte nicht angelegt werden:', ausnahme)
return { fehler: 'Der Kontakt konnte nicht angelegt werden.' }
}
}
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
BETRAGS_TOLERANZ_PROZENT,
belegBegruendet,
belegDurchVertrag,
belegFehlt,
belegquote,
betragPasstZumVertrag,
betragPruefen,
type BuchungEingabe,
} from '@/lib/finanzen/kassenbuch'
/**
* Dauerzahlungen zu einem Vertrag.
*
* Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf
* gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police.
*
* Der gefährliche Fehler wäre hier nicht ein zu strenges Urteil, sondern ein
* zu mildes: Wenn eine verdreifachte Prämie als „belegt" durchgeht, ist die
* Zuordnung eine Hintertür geworden. Die Hälfte dieser Prüfungen zielt
* deshalb darauf, dass die Anerkennung auch wirklich **wegfällt**.
*/
function buchung(teile: Partial<BuchungEingabe> = {}): BuchungEingabe {
return {
id: 'b1',
datum: new Date('2026-03-01T00:00:00Z'),
betragCent: 4250n,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstelle: 'Allianz',
verwendungszweck: null,
kontoId: 'k1',
belegId: null,
belegFehltBegruendung: null,
betrifftBetreute: true,
vermoegenswirksam: false,
...teile,
}
}
/** Monatlicher Vertrag über 42,50 EUR. */
const MONATLICH = { vertragId: 'v1', vertragBeitragCent: 4250n, vertragRhythmus: 'MONATLICH' as const }
describe('Betrag gegen den hinterlegten Beitrag', () => {
it('erkennt die passende monatliche Rate', () => {
expect(betragPasstZumVertrag(4250n, 4250n, 'MONATLICH')).toBe(true)
})
it('lässt eine Beitragsanpassung durchgehen', () => {
// 46,00 statt 42,50 - knapp acht Prozent, das ist eine normale Anpassung.
expect(betragPasstZumVertrag(4600n, 4250n, 'MONATLICH')).toBe(true)
})
it('meldet die Prämie, die von 42 auf 420 springt', () => {
expect(betragPasstZumVertrag(42_000n, 4250n, 'MONATLICH')).toBe(false)
})
it('meldet auch die Zahlweise, die nicht zum Vertrag passt', () => {
// Am Vertrag steht die Monatsrate, die Bank bucht jährlich ab
// (12 x 42,50 = 510,00). Das gilt bewusst NICHT als in Ordnung: Dann
// stimmen die Vertragsdaten nicht, und das soll auffallen.
//
// Ein früherer Entwurf hat solche Umrechnungen zugelassen — und damit
// ein Toleranzband mit vier Löchern geschaffen. 420 EUR wären als
// "nahe am Jahresbetrag von 510" durchgegangen, also ausgerechnet der
// Fall, den diese Prüfung fangen soll.
expect(betragPasstZumVertrag(51_000n, 4250n, 'MONATLICH')).toBe(false)
})
it('akzeptiert die Rate, sobald die Zahlweise am Vertrag stimmt', () => {
// Derselbe Fall, nachdem die Zahlweise berichtigt wurde.
expect(betragPasstZumVertrag(51_000n, 51_000n, 'JAEHRLICH')).toBe(true)
})
it('antwortet „unbekannt“, wenn kein Beitrag hinterlegt ist', () => {
// Nicht "in Ordnung". Es soll nicht so aussehen, als wäre geprüft worden.
expect(betragPasstZumVertrag(4250n, null, 'MONATLICH')).toBeNull()
expect(betragPasstZumVertrag(4250n, 0n, 'MONATLICH')).toBeNull()
})
it('antwortet „unbekannt“ ohne Zahlweise und bei EINMALIG', () => {
expect(betragPasstZumVertrag(4250n, 4250n, null)).toBeNull()
// Zu einer Einmalzahlung gibt es keine wiederkehrende Rate.
expect(betragPasstZumVertrag(4250n, 4250n, 'EINMALIG')).toBeNull()
})
it('rechnet mit dem Betrag, nicht mit dem Vorzeichen', () => {
expect(betragPasstZumVertrag(-4250n, 4250n, 'MONATLICH')).toBe(true)
})
it('hält die dokumentierte Toleranz ein', () => {
expect(BETRAGS_TOLERANZ_PROZENT).toBe(20n)
// Genau an der Grenze: 42,50 + 20 % = 51,00.
expect(betragPasstZumVertrag(5100n, 4250n, 'MONATLICH')).toBe(true)
// Knapp darüber nicht mehr.
expect(betragPasstZumVertrag(5200n, 4250n, 'MONATLICH')).toBe(false)
})
})
describe('Belegzustand', () => {
it('zählt eine Dauerzahlung als belegt', () => {
const b = buchung(MONATLICH)
expect(belegDurchVertrag(b)).toBe(true)
expect(belegFehlt(b)).toBe(false)
expect(belegBegruendet(b)).toBe(false)
})
it('lässt die Anerkennung bei abweichendem Betrag wegfallen', () => {
// Der wichtigste Test: Sonst wäre die Zuordnung eine Hintertür.
const b = buchung({ ...MONATLICH, betragCent: 42_000n })
expect(belegDurchVertrag(b)).toBe(false)
expect(betragPruefen(b)).toBe(true)
expect(belegFehlt(b)).toBe(true)
})
it('erkennt eine Zuordnung an, wenn am Vertrag kein Beitrag steht', () => {
// Zuordnung gilt, aber es wird nicht behauptet, sie sei geprüft.
const b = buchung({ vertragId: 'v1', vertragBeitragCent: null, vertragRhythmus: null })
expect(belegDurchVertrag(b)).toBe(true)
expect(betragPruefen(b)).toBe(false)
})
it('lässt einen echten Beleg vorgehen', () => {
const b = buchung({ ...MONATLICH, belegId: 'd1' })
expect(belegDurchVertrag(b)).toBe(false)
expect(belegFehlt(b)).toBe(false)
})
it('bleibt ohne Zuordnung beim alten Verhalten', () => {
expect(belegFehlt(buchung())).toBe(true)
expect(belegBegruendet(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(true)
expect(belegFehlt(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(false)
})
it('lässt eine Begründung nicht neben den Vertrag treten', () => {
// Der Vertrag ist die stärkere Aussage; doppelt gezählt würde die
// Summe der Kategorien die Gesamtzahl übersteigen.
const b = buchung({ ...MONATLICH, belegFehltBegruendung: 'Dauerauftrag' })
expect(belegDurchVertrag(b)).toBe(true)
expect(belegBegruendet(b)).toBe(false)
})
})
describe('Belegquote', () => {
it('weist die drei Fälle getrennt aus', () => {
const quote = belegquote([
buchung({ id: '1', belegId: 'd1' }),
buchung({ id: '2', ...MONATLICH }),
buchung({ id: '3', belegFehltBegruendung: 'Barzahlung' }),
buchung({ id: '4' }),
])
expect(quote.gesamt).toBe(4)
expect(quote.mitBeleg).toBe(1)
expect(quote.durchVertrag).toBe(1)
expect(quote.begruendet).toBe(1)
expect(quote.ohneNachweis).toBe(1)
expect(quote.quoteProzent).toBe(75)
})
it('rechnet die Kategorien überschneidungsfrei', () => {
// Jede Buchung zählt in genau eine Kategorie - sonst ergäbe die Summe
// mehr als das Ganze.
const buchungen = [
buchung({ id: '1', belegId: 'd1' }),
buchung({ id: '2', ...MONATLICH }),
buchung({ id: '3', ...MONATLICH, belegFehltBegruendung: 'egal' }),
buchung({ id: '4', belegFehltBegruendung: 'Barzahlung' }),
buchung({ id: '5' }),
buchung({ id: '6', ...MONATLICH, betragCent: 99_000n }),
]
const q = belegquote(buchungen)
expect(q.mitBeleg + q.durchVertrag + q.begruendet + q.ohneNachweis).toBe(q.gesamt)
})
it('zählt eine abweichende Zahlung als zu prüfen und nicht als belegt', () => {
const quote = belegquote([buchung({ ...MONATLICH, betragCent: 42_000n })])
expect(quote.durchVertrag).toBe(0)
expect(quote.zuPruefen).toBe(1)
expect(quote.ohneNachweis).toBe(1)
expect(quote.quoteProzent).toBe(0)
})
it('bleibt bei null Buchungen bei 100 Prozent', () => {
expect(belegquote([]).quoteProzent).toBe(100)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
SCHREIBEN_ARTEN,
SCHREIBEN_EIGENSCHAFTEN,
dateinameFuerSchreiben,
istSchreibenArt,
} from '@/lib/schreiben/arten'
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert'
import { NACHFASSEN_AB_TAGEN } from '@/lib/vorgaenge/sachstandsanfrage'
/**
* Schreiben erzeugen — die Teile ohne Datenbank.
*
* Der Schwerpunkt liegt auf zwei Dingen, die sonst still auseinanderlaufen:
* den Wiedervorlagefristen (sie stehen an zwei Stellen) und dem Dateinamen
* (er landet im Objektspeicher und im Download).
*/
describe('Arten von Schreiben', () => {
it('erkennt bekannte Arten und weist alles andere ab', () => {
expect(istSchreibenArt('RUECKKAUFSWERT')).toBe(true)
expect(istSchreibenArt('FREI')).toBe(true)
expect(istSchreibenArt('etwas-anderes')).toBe(false)
expect(istSchreibenArt(null)).toBe(false)
expect(istSchreibenArt(undefined)).toBe(false)
})
it('beschreibt jede Art vollständig', () => {
for (const art of SCHREIBEN_ARTEN) {
const eigenschaft = SCHREIBEN_EIGENSCHAFTEN[art]
expect(eigenschaft.beschriftung.length, art).toBeGreaterThan(3)
// Wo es eine Frist gibt, muss auch dastehen, warum sie so lang ist.
if (eigenschaft.wiedervorlageTage !== null) {
expect(eigenschaft.fristBegruendung, art).toBeTruthy()
}
}
})
it('schlägt für ein freies Schreiben KEINE Frist vor', () => {
// Bei einem freien Schreiben weiß niemand, ob und wann eine Antwort
// kommt. Eine erfundene Frist wäre schlechter als keine.
expect(SCHREIBEN_EIGENSCHAFTEN.FREI.wiedervorlageTage).toBeNull()
})
})
describe('Wiedervorlagefristen bleiben mit den Modulen im Gleichschritt', () => {
it('nimmt beim Rückkaufswert dieselbe Zahl wie das Vertragsmodul', () => {
// Sie steht dort für die Anzeige am Vertrag, hier für die erzeugte
// Frist. Laufen sie auseinander, mahnt die Anwendung zu zwei
// verschiedenen Terminen — und man glaubt keinem mehr.
expect(SCHREIBEN_EIGENSCHAFTEN.RUECKKAUFSWERT.wiedervorlageTage).toBe(
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
)
})
it('nimmt bei der Sachstandsanfrage dieselbe Zahl wie das Vorgangsmodul', () => {
expect(SCHREIBEN_EIGENSCHAFTEN.SACHSTAND.wiedervorlageTage).toBe(NACHFASSEN_AB_TAGEN)
})
})
describe('Dateiname', () => {
const tag = new Date('2026-08-23T00:00:00.000Z')
it('beginnt mit dem Datum, damit die Dateien sortierbar bleiben', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'docx')
expect(name.startsWith('2026-08-23_')).toBe(true)
expect(name.endsWith('.docx')).toBe(true)
})
it('nennt Art und Empfänger', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'pdf')
expect(name).toContain('Kuendigung')
expect(name).toContain('Allianz')
expect(name.endsWith('.pdf')).toBe(true)
})
it('löst Umlaute auf und wirft Sonderzeichen heraus', () => {
// Der Name geht in den Objektspeicher und über einen HTTP-Kopf. Beides
// verträgt keine Umlaute und keine Anführungszeichen.
const name = dateinameFuerSchreiben('FREI', 'Müller & Co. "Süd"', tag, 'docx')
// Nur der Namensteil — der Punkt vor der Endung gehört dazu.
const ohneEndung = name.replace(/\.docx$/, '')
expect(name).not.toMatch(/[^\x20-\x7E]/)
expect(ohneEndung).not.toMatch(/["&.]/)
expect(name).toContain('Mueller')
expect(name).toContain('Sued')
})
it('kommt ohne Empfänger aus', () => {
const name = dateinameFuerSchreiben('FREI', null, tag, 'docx')
expect(name).toBe('2026-08-23_Freies-Schreiben.docx')
})
it('kürzt einen sehr langen Empfänger, statt den Namen ausufern zu lassen', () => {
const lang = 'Sehr Lange Versicherungsgesellschaft auf Gegenseitigkeit in Oberfranken'
const name = dateinameFuerSchreiben('FREI', lang, tag, 'pdf')
expect(name.length).toBeLessThan(90)
})
})
+42
View File
@@ -8,6 +8,7 @@ import {
einzelneZahlungen,
gruppieren,
normalisieren,
eindeutigerVertrag,
offeneKandidaten,
rhythmusAusAbstand,
rhythmusErklaerung,
@@ -281,6 +282,22 @@ describe('Gruppieren', () => {
expect(gruppen[0]!.jahresbetragCent).toBe(30_000n)
})
it('sagt, wie viele Zahlungen der Gruppe schon an einem Vertrag hängen', () => {
// Grundlage der Sammelzuordnung (Issue #11): Nur der Rest muss noch
// verknüpft werden, nicht alles noch einmal.
const zahlungen = monatlich('Debeka AG', 30, 4)
zahlungen[0]!.vertragId = 'v1'
zahlungen[1]!.vertragId = 'v1'
const gruppe = gruppieren(zahlungen, {
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }],
})[0]!
expect(gruppe.anzahl).toBe(4)
expect(gruppe.bereitsZugeordnet).toBe(2)
expect(gruppe.offeneBuchungIds).toHaveLength(2)
})
it('zählt für das Dashboard nur ungeklärte Gruppen', () => {
const gruppen = gruppieren(
[...monatlich('Debeka AG', 30, 4), ...monatlich('Unbekannt Versicherung', 20, 4)],
@@ -351,3 +368,28 @@ describe('Erklärung unter der Gruppe', () => {
expect(rhythmusErklaerung(gruppe)).toContain('ohne erkennbaren Abstand')
})
})
describe('Eindeutige Vertragszuordnung beim Import', () => {
const vertraege = [
{ id: 'v1', bezeichnung: 'Hausrat', gesellschaft: 'Allianz Versicherung' },
{ id: 'v2', bezeichnung: 'Haftpflicht', gesellschaft: 'Allianz Versicherung' },
{ id: 'v3', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' },
]
it('ordnet zu, wenn genau ein Vertrag passt', () => {
expect(eindeutigerVertrag('Debeka', vertraege)?.id).toBe('v3')
})
it('ordnet NICHT zu, wenn zwei Verträge zur selben Gesellschaft gehören', () => {
// Hausrat und Haftpflicht bei derselben Gesellschaft. Eine falsche
// Zuordnung wäre schlimmer als gar keine — sie behauptet einen Beleg,
// und niemand sieht noch einmal hin.
expect(eindeutigerVertrag('Allianz Versicherung', vertraege)).toBeNull()
})
it('ordnet nichts zu, wenn nichts passt', () => {
expect(eindeutigerVertrag('Stadtwerke Bamberg', vertraege)).toBeNull()
expect(eindeutigerVertrag(null, vertraege)).toBeNull()
expect(eindeutigerVertrag('', vertraege)).toBeNull()
})
})