Compare commits

..
55 Commits
Author SHA1 Message Date
florian.wilde 3525777fb4 Merge #38: Sicherheitsprüfung abarbeiten (v1.11.0) 2026-08-24 10:35:19 +00:00
Florian Wilde 406f7854b7 chore: Version 1.11.0 2026-08-24 10:06:13 +02:00
Florian Wilde e48eda4332 Irrefuehrende Sicherheitszusagen entfernen, OWASP-Durchgang festhalten
Zwei Kommentare versprachen Schutz, den es nicht gab. Das ist schlimmer
als eine fehlende Zusage, weil man sich darauf verlaesst:

- In middleware.ts stand "Ohne Authentifizierungs-Cookie gar nicht erst
  weiterreichen". Die Middleware prueft keine Anmeldung, sie setzt
  Kopfzeilen; die Liste steuert allein den Cache-Control-Kopf. Wer den
  Satz glaubt, verzichtet an einer neuen Seite auf erfordereBenutzer().
  Die Liste heisst jetzt OHNE_CACHE_SPERRE und sagt selbst, was sie ist.

- In datei.ts hiess es, die Pfadpruefung fange "Symlink-Tricks" ab.
  resolve() rechnet mit Zeichenketten und folgt keinem Link - dafuer
  braeuchte es realpath(). Nachgeruestet wird es nicht: Wer dort einen
  Symlink anlegen kann, hat schon Schreibrechte im Datenverzeichnis. Der
  Satz musste trotzdem weg.

Dazu docs/SICHERHEIT.md: Bedrohungsmodell, der Durchgang entlang der
OWASP Top 10 - und ein eigener Abschnitt darueber, was ausdruecklich NICHT
geschuetzt ist.

Aus dem OWASP-Durchgang kamen keine weiteren Funde. Zwei Stellen sahen
danach aus und waren es nicht: Prisma.join() in suche.ts fuegt nur bereits
parametrisierte Fragmente zusammen, und der Suchausschnitt mit den
<mark>-Markern aus ts_headline wird zerlegt und als React-Kinder
gerendert, nicht als HTML - obwohl die Quelle der OCR-Text hochgeladener
Dokumente ist.

Refs #38
2026-08-24 09:53:43 +02:00
Florian Wilde 92ff8efe39 Objektspeicher verschluesseln, statt es dem Anbieter zu ueberlassen
Im S3-Treiber stand nur ServerSideEncryption: 'AES256'. Das ist
Verschluesselung mit dem Schluessel des Anbieters - er entschluesselt beim
Ausliefern selbst. Gegen eine verlorene Platte im Rechenzentrum hilft das,
gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht. In
diesem Eimer liegen Bescheide, Arztbriefe und Kontoauszuege.

Der Dateitreiber hat es von Anfang an richtig gemacht. Der Umschlag zieht
deshalb nach src/lib/speicher/umschlag.ts um und wird von beiden benutzt,
statt ihn ein zweites Mal zu schreiben.

Neu ist die Bindung an den Ablageort: Der Schluessel des Objekts wandert
in die Zusatzdaten. Wer Schreibrechte auf den Eimer hat, kann die Bytes
von Dokument A damit nicht unter dem Namen von Dokument B ablegen - beim
Lesen schlaegt die Pruefung fehl, statt den falschen Bescheid mit
gueltiger Pruefsumme auszuliefern.

Damit fallen die presigned URLs weg. Sie liessen den Browser direkt beim
Anbieter laden; was dort liegt, ist jetzt Geheimtext. Die Auslieferung
geht ueber dieselbe eigene Route wie beim Dateitreiber - die prueft
zusaetzlich die Berechtigung und schreibt einen Audit-Eintrag. Ein
weitergeleiteter Link allein oeffnet also nichts mehr.

Bestehende Dateien bleiben durchgehend lesbar: Der Treiber erkennt am
Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
Auch die alten Umschlaege ohne Ortsbindung gehen weiter auf - sonst waere
die Verbesserung ein Datenverlust. Nachgezogen wird mit

    npm run speicher:verschluesseln            # nur schauen
    npm run speicher:verschluesseln -- --anwenden

Geprueft: 12 Tests gegen drei Mutationen, ein Trockenlauf ueber die 120
Dateien der Entwicklungsablage, und eine Probe mit einer Klartextdatei,
die nach dem Lauf verschluesselt auf der Platte liegt und sich trotzdem
korrekt zurueeklesen laesst.

Refs #38
2026-08-24 09:50:41 +02:00
Florian Wilde 676fbb8ee4 Texterkennung in Grenzen zwingen, die das Dokument nicht selbst setzt
Zwei Wege, auf denen ein hochgeladenes Dokument den Server umbringen
konnte. Beide haben gemeinsam, dass die Groesse aus der Datei kam.

1. Dekompressionsbombe. Die PDF-Stroeme wurden mit inflateSync ohne
   maxOutputLength ausgepackt. Ein Flate-Strom sagt nicht, wie gross er
   ausgepackt wird - ein paar Kilobyte Fuellung blaehen sich auf Gigabyte
   auf. Jetzt ein Deckel je Strom (8 MB) und einer ueber die Summe
   (64 MB); zlib bricht selbst ab, der Strom wird verworfen, der Text ist
   eben unvollstaendig.

2. Riesenseiten. "pdftoppm -r 300" rendert mit fester Aufloesung, aber
   die Seitengroesse steht im PDF. Bei 200 x 200 Zoll ergeben 300 dpi ein
   Bild von 60000 x 60000 Pixeln, und zwar je Seite. Jetzt "-scale-to"
   statt fester dpi: Die laengste Kante bekommt eine feste Pixelzahl,
   unabhaengig davon, was im Dokument als Papierformat steht. Fuer A4
   entspricht das etwa den bisherigen 300 dpi. Dazu ein Deckel auf die
   Gesamtgroesse der gerenderten Dateien.

Der erste Bombentest war wertlos: Die Nutzlast enthielt keine
PDF-Textoperatoren, also war das Ergebnis mit und ohne Grenze leer. Mit
einer Marke in der Nutzlast und einer Gegenprobe knapp unter der Grenze
schlagen beide Mutationen an.

Refs #38
2026-08-24 09:41:35 +02:00
Florian Wilde 5b54d7bc5f Ersteinrichtung schliessen: Geheimnis und eine Sperre statt zweier Schritte
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
Anmeldung schreiben darf. Zwei Dinge fehlten.

Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des
Zugangs fuer jeden offen: Wer sie in diesem Fenster fand, wurde
Administrator einer Anwendung mit Gesundheits- und Finanzdaten. Das
Fenster ist kurz, aber Zufall ist keine Zugriffskontrolle. Jetzt muss
ERSTEINRICHTUNG_GEHEIMNIS aus der .env mit eingegeben werden; verglichen
wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert
antwortet mit derselben Mindestdauer wie das Anmeldeformular.

Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist
Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine
Meldung zeigen und nicht einrichtbar sein.

Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in
einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine
zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein
zweiter Administrator entsteht.

Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb,
stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls.

Refs #38
2026-08-24 08:03:49 +02:00
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00
Florian Wilde 6bd8f7e8a4 Schema: Zeitschritt fuer TOTP und Sperre fuer die Ersteinrichtung
Zwei Felder fuer zwei Luecken, in einer Migration:

benutzer.totpLetzterSchritt haelt das zuletzt akzeptierte TOTP-Fenster
fest. Ohne das gilt ein Code bis zu 90 Sekunden lang beliebig oft.

Die Tabelle ersteinrichtung hat genau eine Zeile mit fester Id. Das ist
kein Protokoll, sondern eine Sperre: Bisher entschied "gibt es schon
einen Benutzer?" und danach wurde angelegt - zwei Anfragen gleichzeitig
kamen beide durch. Jetzt entscheidet der Primaerschluessel.

Enthalten ist eine Datenmigration: Wo schon ein Zugang besteht, wird die
Sperrzeile gesetzt. Ohne sie stuende die Einrichtungsseite auf jedem
bestehenden System nach dem Update wieder offen.

Die DROP-INDEX-Zeilen zu den Trigramm-Indizes hat das Skript aus #36
wieder entfernt; "npm run db:pruefen" bestaetigt, dass beide stehen.

Refs #38
2026-08-24 08:03:32 +02:00
Florian Wilde 133b0314c5 Anmeldesperre gegen Selbstverlaengerung und Schreiblast sichern
Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.

Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.

Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.

Die Tests sind gegen vier Mutationen geprueft; jede wird rot.

Refs #38
2026-08-24 02:13:04 +02:00
Florian Wilde 2bed78915f Zweiten Faktor zur Grenze machen, nicht nur zur Anzeige
"Der zweite Faktor ist Pflicht" stand im Kopf von auth.ts, durchgesetzt
wurde es aber nur im App-Layout. Ein Layout schuetzt, was durch das
Layout gerendert wird - Server Actions und Route Handler laufen daran
vorbei. Wer eine Passwort-Sitzung hatte, die TOTP-Einrichtung aber nie
abgeschlossen hat, kam an die Daten.

Die Pruefung steckt jetzt in erfordereBenutzer() selbst und damit ueber
erfordereZugang() automatisch in jeder Server Action. Massgeblich ist
der Datenbankstand, nicht das Feld im Sitzungstoken - das laesst sich
ueber session.update() vom Client beeinflussen.

Fuer Route Handler gibt es benutzerFuerRoute(): 401 bzw. 403 statt einer
Umleitung. Eine Anmeldeseite als Inhalt einer Word-Datei waere die
falsche Antwort.

Dabei aufgefallen: Der Dokumentenabruf unter /api/dokumente/datei fragte
nur nach der Anmeldung. Auch geschlossen.

Ausnahmen sind aufgezaehlt und werden von tests/mfa-grenze.test.ts
festgehalten - der Test liest den Quelltext und schlaegt an, sobald eine
weitere dazukommt.

Refs #38
2026-08-24 02:12:50 +02:00
Florian Wilde 2c1b8f4c28 Abhaengigkeiten auf Fassungen ohne bekannte Luecken heben
npm audit meldete 32 Schwachstellen, davon 5 kritische. Der Vorschlag
des Werkzeugs war ein Sprung auf Next.js 16 - noetig war er nicht: next,
next-auth und @auth/core waren nur durchgereicht markiert, ein eigenes
Advisory hatten allein nodemailer, postcss und sharp. Die drei sind
Blattpakete und lassen sich gezielt ueberschreiben.

Nodemailer 9 kollidiert mit einer optionalen Peer-Angabe von next-auth
(hoechstens 8). Das ist folgenlos, weil der E-Mail-Provider von next-auth
hier nicht benutzt wird, sondern Nodemailer direkt.

Danach: 0 Schwachstellen.

Refs #38
2026-08-24 02:12:37 +02:00
florian.wilde ece995bb6a Merge #37: Update brach ohne Meldung ab
Abgeschnittene Leitungen unter pipefail beendeten update.sh stumm und
haetten die Selbstpruefung der Sicherung falsch negativ gemacht. Behoben,
mit ERR-Falle und Test gegen das Muster.
2026-08-23 22:36:53 +00:00
Florian Wilde 107406e3c0 Update brach ohne Meldung ab
Beim Sprung von 1.5.0 auf 1.10.1 endete scripts/update.sh mitten in der
Ausgabe. Kein Fehler, keine Zeile, einfach zurueck zur
Eingabeaufforderung.

Ursache war eine abgeschnittene Leitung:

  git log --oneline "HEAD..$ZIEL" | head -15 | sed ...

head schliesst die Leitung nach 15 Zeilen, git log schreibt weiter und
bekommt SIGPIPE (141). Mit "set -o pipefail" gilt die ganze Kette als
gescheitert, "set -e" beendet das Skript - stumm.

Zeitabhaengig, deshalb monatelang unauffaellig: Solange git log fertig
wurde, bevor head zumachte, ging es gut. Die Schwelle liegt exakt bei
15 Commits, und v1.5.0..v1.10.0 waren die ersten 16.

Behoben, indem der Erzeuger selbst begrenzt (git log -15) statt die
Leitung zu kappen. Dieselbe Behandlung fuer die beiden Tag-Listen.

Dieselbe Falle steckte in der Selbstpruefung von sicherung.sh:

  openssl enc -d ... | head -c 5 | grep -q "PGDMP"

Beide Leser schliessen frueh. Bei kleiner Datenbank steht alles im
Puffer und es faellt nicht auf; mit wachsender Datenbank kippt es - und
dann meldet das Skript eine INTAKTE Sicherung als kaputt und verweigert
das Update. Jetzt liest dd eine feste Menge und endet regulaer.

Damit so etwas nicht wieder stumm passiert:
- Eine ERR-Falle nennt Zeilennummer und Exitcode und erklaert bei 141,
  dass es SIGPIPE ist und ein Fehler im Skript, nicht beim Benutzer.
- Ein Test verbietet "| head" in Skripten mit pipefail und nennt die
  Zeile. Gegenprobe gemacht: Er schlaegt an.
- Bei mehr als 15 Commits steht "... und N weitere" darunter, statt sie
  wortlos wegzulassen.

Nachgestellt: der Ablauf von 1.5.0 auf 1.10.1 laeuft durch.
2026-08-24 00:36:08 +02:00
florian.wilde 9ea78a4d29 Merge #36: Zeilenenden im Repository auf LF
Behebt Issue #26: .gitattributes legt LF fest, 29 Dateien normalisiert.
Nachgewiesen nur Zeilenenden, kein Zeichen Inhalt geaendert.

Closes #26
2026-08-23 22:28:47 +00:00
Florian Wilde 1ed19eb76e Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
2026-08-23 23:37:02 +02:00
Florian Wilde 6a0db51e3b Zeilenenden im Repository auf LF festlegen
Ohne .gitattributes liegen Dateien gemischt vor: 29 von 500 mit CRLF,
der Rest mit LF. Jedes zeilenweise arbeitende Werkzeug schreibt eine
CRLF-Datei dann mit LF zurueck, und der Diff zeigt statt drei
geaenderten Zeilen die ganze Datei - bei Issue #7 waren es 578 statt 8,
kurz vor dem Push bemerkt.

Dieser Commit legt nur die Regel fest. Die Normalisierung der 29 Dateien
kommt getrennt, damit die Historie lesbar bleibt.

Refs #26
2026-08-23 23:34:38 +02:00
florian.wilde 851724774a Merge #35: Doppelt gerenderte Listen dokumentieren
Behebt Issue #28: Kommentar an der Dokumentenliste, Muster und drei
tragfaehige Wege in e2e/hilfen.ts, Begruendung warum die Verdopplung
bleibt.

Closes #28
2026-08-23 21:29:15 +00:00
Florian Wilde 3772e360e1 Doppelt gerenderte Listen dokumentieren
Die Dokumentenliste rendert jeden Eintrag zweimal: als Karte fuers Handy,
als Tabellenzeile ab Tablet. Eine Fassung ist per Breakpoint versteckt,
steht aber im DOM. Beim Testen trifft getByText(...).first() deshalb die
unsichtbare Kopie, und die Pruefung scheitert mit "element is not
visible" an einer Stelle, die augenscheinlich in Ordnung ist. Das hat
mehrfach Zeit gekostet, weil man den Grund nicht sieht.

Nachgemessen an /dokumente mit acht Dokumenten: ein Titel liefert 16
Treffer, davon 8 sichtbar - und first() ist unsichtbar.

- Kommentar an der Komponente, mit dem Verweis auf die Hilfsdatei.
- In e2e/hilfen.ts das Muster beschrieben und drei Wege genannt, die
  tragen: innerText fuer "steht das da", visible=true zum Klicken, die
  Zusammenfassungszeile fuer Anzahlen. Alle drei nachgeprueft, bevor sie
  aufgeschrieben wurden - eine Anleitung mit kaputtem Rezept waere
  schlimmer als keine.

Zur Frage aus dem Issue, ob sich die Verdopplung vermeiden laesst: nein,
nicht ohne Verlust. Eine Tabelle, die zu Karten umbricht, verlangt
display:block auf Zeilen und Zellen - damit ist es keine Tabelle mehr,
Vorlesesoftware verliert den Zusammenhang von Spaltenkopf und Wert, und
genau der macht eine Liste mit acht Spalten erst benutzbar. Die
Begruendung steht jetzt am Code, statt die Frage offen zu lassen.

Betroffen ist uebrigens nur diese eine Stelle, nicht mehrere:
kalender-monat.tsx und das Stammdatenformular nutzen Breakpoints, ohne
Inhalt doppelt zu rendern.

Refs #28
2026-08-23 23:28:22 +02:00
florian.wilde d75eb185d5 Merge #34: Verweise auf fremde Akten an einer Stelle pruefen
Behebt Issue #23: Durchgang durch alle Server Actions, eine Luecke
geschlossen (buchung.vorgangId), die Pruefung in lib/auth/verweise.ts
zusammengefasst.

Closes #23
2026-08-23 21:21:40 +00:00
Florian Wilde 91a9aca0d8 Verweise auf fremde Akten an einer Stelle pruefen
Wenn ein Formular einen Fremdschluessel mitschickt, kommt der aus einer
Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen -
eine veraenderte Anfrage kann jede beliebige Id enthalten.

Alle 22 Dateien unter src/server/actions/ durchgesehen. Das Ergebnis ist
besser als befuerchtet: Bis auf eine Stelle war jeder Verweis geprueft.

  buchung.vorgangId ging ungeprueft durch. Konto, Beleg und Vertrag
  wurden geprueft, der Vorgang nicht.

Die Pruefung liegt jetzt in lib/auth/verweise.ts statt verstreut in den
Aktionen. Die acht erlaubten Ziele sind eine ausgeschriebene Aufzaehlung
und kein dynamischer Tabellenzugriff: So sieht der Typpruefer jede
Abfrage, und welche Tabellen erreichbar sind, ist eine Entscheidung und
keine Nebenwirkung. Mehrere Verweise laufen nebeneinander - bei vier
Verweisen eine Wartezeit statt vier.

Umgestellt: die vier Einzelpruefungen der Buchung, die drei des
Vermoegenspostens, die eigene Hilfsfunktion fuer den Ansprechpartner.
Nicht angefasst wurden die Stellen, die ihre eigene, passendere Form
haben - etwa das Kontaktprotokoll, das beide Verweise parallel laedt und
feldgenaue Meldungen zurueckgibt.

Zum Test: Der erste Entwurf rief die Server Action per fetch nach. Der
waere auch ohne jede Pruefung gruen gewesen, weil Next einen Aufruf ohne
gueltige Action-Id ohnehin ablehnt - er haette nichts bewiesen. Der
jetzige schiebt dem echten Auswahlfeld einen Eintrag mit fremder Id
unter und schickt das Formular normal ab. Gegenprobe mit ausgebauter
Pruefung: faellt um, wie er soll.

Refs #23
2026-08-23 23:20:26 +02:00
florian.wilde 91592cbe88 Merge #33: Ein Jahr Kontobewegungen im Beispielfall
Behebt Issue #22: 107 Buchungen ueber zwoelf Monate im Demofall, mit
gerechnetem Kontostand. Dazu behoben: gGmbH fehlte in der Rechtsformliste
beim Zusammenfassen wiederkehrender Zahlungen.

Closes #22
2026-08-23 19:36:49 +00:00
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00
florian.wilde b6b579a04d Merge #32: Absenderanschrift in jedem Schreiben
Behebt Issue #21: Strasse, PLZ und Ort unter "Mein Konto", von dort in
den Absenderblock jedes Schreibens und als Ort in die Datumszeile. Was
fehlt, bleibt weg.

Closes #21
2026-08-23 18:30:23 +00:00
Florian Wilde 848aaf1b9f Absenderanschrift in jedem Schreiben
Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
2026-08-23 20:29:17 +02:00
florian.wilde 5022a97a6f Merge #31: Datenbankbefehle benennen, welcher Rueckfragen stellt
Behebt Issue #27: Uebersicht der sieben Datenbankbefehle in der README,
db:migrate als einziger interaktiver benannt, Ersteinrichtung auf
npm run setup vereinheitlicht.

Closes #27
2026-08-23 17:36:06 +00:00
Florian Wilde 110b11bef8 Datenbankbefehle benennen, welcher Rueckfragen stellt
"npm run db:migrate" ist "prisma migrate dev" und damit interaktiv: Es
fragt nach einem Namen und bietet an, die Datenbank zurueckzusetzen. In
einem Skript, in der CI oder aus einem Agenten heraus bleibt es haengen.
Aus dem Namen ging das nicht hervor, also lief man einmal hinein.

- Neuer Abschnitt in der README mit allen sieben Datenbankbefehlen,
  wofuer sie da sind und welcher Rueckfragen stellt. Es ist genau einer.
- Der uebliche Weg fuer eine Schemaaenderung steht als Dreizeiler
  daneben - ueber den Wrapper aus #24, nicht ueber migrate dev.
- Die Ersteinrichtung in der README rief "npx prisma migrate dev --name
  fundament" auf, obwohl es dafuer "npm run setup" gibt. Das war der
  Ausgangspunkt der Verwirrung; jetzt steht dort der eine Befehl.
- Der Hinweis unter "Betriebswissen", die SQL-Skripte seien von Hand
  anzustossen, galt nur fuer die Ersteinrichtung. Beim Aktualisieren
  erledigt update.sh das mit - jetzt steht das dabei.

"db:migrate" bleibt bestehen. Der interaktive Ablauf hat seinen Platz,
etwa um eine verkorkste Entwicklungsdatenbank zurueckzusetzen; er ist
nur nicht mehr der Standardweg.

Refs #27
2026-08-23 19:35:20 +02:00
florian.wilde 4aed9824d1 Merge #30: Migrationskommentare zeigen auf die richtige Datei
Behebt Issue #25: prisma/sql/suche.sql gibt es nicht, richtig ist
volltextsuche.sql. Dazu ein Verweis auf npm run db:migration aus #24.

Closes #25
2026-08-23 17:31:35 +00:00
Florian Wilde e19300b33d Migrationskommentare zeigen auf die richtige Datei
Fuenf Migrationen verwiesen fuer die Herkunft der Trigramm-Indizes auf
prisma/sql/suche.sql. Diese Datei gibt es nicht; sie heisst
volltextsuche.sql. Der Hinweis steht genau dort, wo ihn jemand liest,
der die naechste Migration erzeugt - ein falscher Pfad schickt ihn ins
Leere.

Dazu ein Verweis auf das Werkzeug aus #24: "npm run db:migration" nimmt
die Zeilen inzwischen selbst heraus, die Kommentare rieten aber
weiterhin nur zum Nachsehen.

Geprueft, bevor die Dateien angefasst wurden: Prisma speichert zwar eine
Pruefsumme je Migration, "migrate deploy" verifiziert sie fuer bereits
angewendete Migrationen aber nicht. Nachgestellt mit genau der Lage, die
auch auf dem Server entsteht - Pruefsumme in der Datenbank weicht ab,
"migrate status" und "migrate deploy" bleiben zufrieden.

Refs #25
2026-08-23 19:30:46 +02:00
florian.wilde 41e33ad85f Merge #29: Migrations-Wrapper gegen das Loeschen der Trigramm-Indizes
Behebt Issue #24: npm run db:migration erzeugt Migrationen ohne die
bekannten DROP-INDEX-Zeilen und prueft nach dem Anwenden, dass die
Indizes stehen.

Closes #24
2026-08-23 17:25:52 +00:00
Florian Wilde 3153e6179d Migrationen erzeugen, ohne die Suche kaputtzumachen
"prisma migrate diff" kennt die Trigramm-Indizes aus
prisma/sql/volltextsuche.sql nicht und schreibt deshalb in jede
Migration ein DROP INDEX dafuer. Fuenfmal mussten die Zeilen von Hand
raus. Auf dem Server waere es folgenlos geblieben - update.sh legt die
Indizes hinterher neu an -, in der Entwicklung dagegen waere die Suche
still langsam geworden.

  npm run db:migration -- <name> [--anwenden]
  npm run db:pruefen

Aufbau:
- Die Liste der geschuetzten Indizes steht an einer Stelle. Wer in
  prisma/sql/ einen hinzufuegt, traegt ihn dort ein.
- dropZeilenEntfernen() ist bewusst eng: Nur eine DROP-Anweisung, die
  einen geschuetzten Index nennt, faellt weg. Ein DROP INDEX auf einen
  Prisma-eigenen Index bleibt stehen - der soll ja weg. Ein Kommentar,
  der den Namen bloss erwaehnt, ebenfalls.
- Der Kopf der erzeugten Datei sagt, was entfernt wurde und warum.
- Nach --anwenden wird gegen die Datenbank geprueft, ob die Indizes
  wirklich noch stehen. Das ist der Unterschied zwischen "sollte
  gutgegangen sein" und "nachgesehen".

Beim Probelauf gefunden und behoben: Stimmen Schema und Datenbank
ueberein, besteht der ganze Diff aus den beiden bekannten DROP-Zeilen.
Das Skript legte dafuer eine leere Migration an - und eine leere
Migration in der Historie ist schlimmer als keine, weil sie als
angewendet gilt und niemand mehr weiss, wofuer sie gut war. Jetzt bricht
es vorher ab, ohne einen Ordner zu hinterlassen.

Die Typen liegen als .d.mts daneben: Das Skript bleibt .mjs und laeuft
ohne Bauschritt, die Tests laufen trotzdem nicht gegen "any".

Geprueft: zwoelf Einheitstests, dazu von Hand der Lauf mit echter
Schemaaenderung und der Lauf ohne.

Refs #24
2026-08-23 19:25:09 +02:00
florian.wilde 98d1338eae Merge #20: Ansprechpartner am Vorgang hinterlegen
Behebt Issue #9: Kontakt als Ansprechpartner am Vorgang, namentliche
Anrede in der Sachstandsanfrage, Name ueber der Behoerde, Anschrift im
Schreiben vorbelegt. Ohne Ansprechpartner bleibt es bei der allgemeinen
Anrede; geraten wird nie.

Nebenbei behoben: Der Schreiben-Editor verwarf die Empfaengeranschrift
aus dem Entwurf.

Closes #9
2026-08-23 14:55:49 +00:00
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00
florian.wilde 5e7d88cf64 Merge #19: Rueckkaufswert am Vertrag sichtbar machen
Behebt Issue #8: letzter Wert mit Stichtag und Quelle am Vertrag,
Kennzeichnung ab einem Jahr, "noch nicht erfasst" statt leerer Zelle.
Ohne Migration - der Wert bleibt der Wertstand am Vermoegensposten, aus
dem auch das Vermoegensverzeichnis rechnet.

Nebenbei behoben: Zuordnung ueber die Verknuepfung statt ueber die Art
des Postens, und die fehlende Zugehoerigkeitspruefung bei den Verweisen
auf Vertrag und Immobilie.

Closes #8
2026-08-23 13:29:54 +00:00
Florian Wilde 09a4528c4b Rueckkaufswert am Vertrag sichtbar machen
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat. Auf der Vertragsseite steht jetzt oben der zuletzt
mitgeteilte Wert mit Stichtag und Quelle, darunter die Historie; in der
Uebersicht traegt ein Wert, der aelter als ein Jahr ist, ein Abzeichen.

Ohne Migration, und das ist keine Einsparung, sondern der Punkt: Die
Werthistorie gibt es schon als Wertstand am Vermoegensposten, und der
kann auf einen Vertrag zeigen. Ein Feld am Vertrag waere genau die
zweite Wahrheit, die das Issue ausschliesst - dieselbe Zahl steht im
Vermoegensverzeichnis, weil es dieselbe Zahl ist.

Aufbau:
- Die Ein-Jahres-Grenze als eine getestete Regel in
  finanzen/wertalter.ts. Sie stand bisher als "> 365" mitten in der
  Vermoegensseite; jetzt gilt sie am Vertrag genauso.
- wertHistorie() fuehrt die Wertstaende aller mit dem Vertrag
  verknuepften Posten zu einer Reihe zusammen.
- Wo ein Rueckkaufswert zu erwarten waere, aber keiner erfasst ist,
  steht "noch nicht erfasst" statt einer leeren Zelle.

Zwei Fehler dabei behoben:
- Vertrag und Vermoegensposten wurden ueber die ART des Postens
  zusammengefuehrt. Ein von Hand als "Sonstiges" angelegter Posten wurde
  nicht gefunden, und beim ersten Erfassen am Vertrag entstand still ein
  zweiter - Bericht und Vertrag haetten verschiedene Zahlen gezeigt.
  Massgeblich ist jetzt die Verknuepfung.
- Beim Speichern eines Vermoegenspostens wurde geprueft, ob das
  verknuepfte Konto zur betreuten Person gehoert; bei Vertrag und
  Immobilie fehlte die Pruefung, beim Vertragsverweis einer Buchung
  ebenso. Bei mehreren betreuten Personen haette ein manipuliertes
  Formular ueber Aktengrenzen hinweg verknuepfen koennen.

Geprueft: zehn Einheitstests, vier End-to-End-Tests - darunter der neu
eingetragene Wert, der unveraendert im Vermoegensverzeichnis
wiederauftaucht, und der Posten mit abweichender Art.

Refs #8
2026-08-23 15:06:25 +02:00
florian.wilde 57ce73dfbc Merge #18: Eigene Vorlagen fuer Anschreiben
Behebt Issue #7: eigene Schreibvorlagen unter Einstellungen, Platzhalter
mit sichtbaren Luecken, zweistufige Bestellungsabsatz-Warnung, vier
mitgelieferte Vorlagen als Kopiervorlage, Auswahl beim freien Schreiben.

Closes #7
2026-08-23 11:59:44 +00:00
Florian Wilde f8d7752c27 Eigene Vorlagen fuer Anschreiben
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.

Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
  Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
  [Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
  Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
  er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
  ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
  speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
  und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
  zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
  Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
  "Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
  und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
  fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
  Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
  beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
  sind im Vollexport ihrer Akte enthalten.

Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.

Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.

Refs #7
2026-08-23 13:41:51 +02:00
florian.wilde 0a8878d8d2 Merge #17: Rueckkaufswerte in einem Zug anfragen
Behebt Issue #6: Ein Klick statt fuenf bis zehn einzelner Briefe - ZIP
je Gesellschaft, PDF in der Akte, Wiedervorlage, Anfragevermerk.

Closes #6
2026-08-23 10:18:02 +00:00
Florian Wilde 9fe6f10f25 Rueckkaufswerte aller Versicherungen in einem Zug anfragen
Vor Vermoegensverzeichnis und Jahresbericht braucht es von jeder Lebens-
und Rentenversicherung den Rueckkaufswert zum Stichtag - jedes Mal
dieselben fuenf bis zehn Briefe. Neu unter Vertraege: Stichtag waehlen,
Liste pruefen, ein Klick. Heraus kommt ein ZIP mit einer Word-Datei je
Gesellschaft plus Begleitblatt; je Vertrag entstehen nebenbei die
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
Anfragevermerk.

Aufbau:
- Auswahllogik als reine Funktionen in vertraege/serienabfrage.ts:
  Vorauswahl, Hindernisse mit Begruendung statt stummem Ausblenden,
  28-Tage-Sperre im Gleichschritt mit der Wiedervorlage.
- Der Lauf in vertraege/serienlauf.ts: je Vertrag das volle Paket aus
  Issue #4, aber in einem Durchgang. Kein Alles-oder-nichts - ein
  gescheiterter Vertrag steht mit Grund im Begleitblatt, die uebrigen
  laufen durch. Eine Transaktion ueber alle waere die falsche Zusage.
- Dublettenschutz tagesgenau: Der Doppelklick erzeugt nichts Doppeltes,
  die bewusste zweite Anfrage nach einer Woche bleibt moeglich.
- Die Anzahl steht vor dem Klick auf dem Knopf.

Gekuendigte Vertraege bleiben anfragbar: Bis zum Wirksamwerden besteht
der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.

Geprueft: zwoelf Einheitstests, drei End-to-End-Tests inklusive der
Nebenwirkungen in der Datenbank und des Dublettenschutzes. 602
Unit-Tests, 87 e2e, null Typfehler.

Refs #6
2026-08-23 12:10:32 +02:00
florian.wilde 96a9bc4d63 Merge #16: Schreiben als E-Mail uebernehmen
Behebt Issue #5: Umschalter Brief/E-Mail im Schreiben-Editor, mailto
fuer kurze Texte, .eml immer, Ablage als gekennzeichnete E-Mail.

Closes #5
2026-08-23 09:18:59 +00:00
Florian Wilde b59d11c9b4 Schreiben als E-Mail uebernehmen
Im Schreiben-Editor gibt es jetzt den Umschalter Brief/E-Mail. Die
E-Mail-Fassung wird aus dem - womoeglich bearbeiteten - Brieftext
abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, aber mit dem
Bestellungsabsatz. Betreff und Text einzeln kopierbar.

Die offene Entscheidung aus dem Issue (mailto oder .eml) ist nach
Rueckfrage mit BEIDES beantwortet:

- mailto fuer kurze Texte. Der Knopf verschwindet, sobald der kodierte
  Link 1800 Zeichen uebersteigt - mailto uebergibt den Text in der
  Adresszeile, deren Laenge nirgends zugesichert ist, und ein halbierter
  Brief faellt erst beim Empfaenger auf. Die Grenze wird NACH der
  URL-Kodierung gerechnet; 900 Umlaute sind roh harmlos und kodiert
  weit drueber.
- .eml immer: RFC 5322 mit X-Unsent: 1, quoted-printable fuer den Rumpf,
  RFC-2047-Kodierung fuer Umlaute im Betreff. Bewusst ohne From-Kopf -
  ein erfundener Absender wuerde je nach Programm uebernommen.

Beide Knoepfe erscheinen nur mit E-Mail-Adresse (aus dem Kontakt
uebernommen oder von Hand). Ohne Adresse bleibt das Kopieren.

Die Ablage kennt den Unterschied: Eine E-Mail heisst in der Akte
"E-Mail: ...", und abgelegt wird die E-Mail-Fassung samt
Empfaengeradresse im Kopf der PDF - in der Akte soll stehen, was
tatsaechlich hinausging.

Sicherung in emailFassung(): Findet sich keine Anrede (frei umgebauter
Text), wird NICHTS entfernt. Lieber ein Briefkopf zu viel in der Mail
als stillschweigend verlorener Inhalt.

Geprueft: 20 Einheitstests, vier End-to-End-Tests auf beiden
Geraeteprofilen. 590 Unit-Tests, 81 e2e, null Typfehler.

Refs #5
2026-08-23 11:02:00 +02:00
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
florian.wilde 8779cc6a25 Merge #13: Wiederkehrende Zahlungen aufspueren
Behebt Issue #3: Am Anfang einer Betreuung weiss niemand, welche
Vertraege es gibt. Die Antwort steht im Kontoauszug.

Closes #3
2026-08-23 06:13:43 +00:00
florian.wilde 928d3bc408 Merge #12: Roadmap - Korrespondenz nach 1.2a
Behebt Issue #10: Das Thema Schriftverkehr stand als eine Zeile unter
"Spaeter, ohne Datum" und war damit zu tief gehaengt.

Closes #10
2026-08-23 06:13:35 +00:00
Florian Wilde aa0cc66292 Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die
Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen
Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen
eine Arbeitsliste.

Aufbau:
- Die Erkennung liegt als reine Funktionen in
  src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich
  jede Einordnung einzeln nachrechnen.
- Der Datenzugriff steht getrennt in wiederkehrend-laden.ts.
- Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt
  am normalisierten Empfaengernamen, nicht an Buchungen - nur so
  ueberlebt die Entscheidung den naechsten Import.

Zwei Haltungen liegen der Erkennung zugrunde:

Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige:
Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende.
Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke
Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu
viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag.

Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort
"6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne
genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern
gezeigt, was geflossen ist.

Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die
Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei
einem importierten Jahr aber nur einmal - und ausgerechnet
Versicherungen zahlt man oft jaehrlich.

Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus
vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat
zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen
haben - darunter ein verlorener Backslash, der die Vorbelegung des
Beitrags stillschweigend verschluckte.

Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende
Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten
zweiten Checkout eingecheckt.

Refs #3
2026-08-23 02:42:26 +02:00
Florian Wilde d28ff2afc1 Roadmap: Schriftverkehr als eigenen Abschnitt 1.2a fuehren
Das Thema stand als einzelne Zeile unter "Spaeter, ohne Datum" — also
unter "waere schoen, und ehrlich gesagt vielleicht nie". Nach der
Priorisierungsregel der Datei selbst faellt es unter Kriterium 2: Es
kostet jeden Monat Zeit, vor Vermoegensverzeichnis und Jahresbericht
besonders viel.

Neuer Abschnitt 1.2a mit begruendeten Unterpunkten zu #3 (Vertraege
aufspueren), #4 (Schreiben erzeugen, ablegen, nachhalten), #5 (E-Mail),
#6 (Serienabfrage der Rueckkaufswerte), #7 (eigene Vorlagen) und #11
(Dauerzahlungen gelten als belegt). Die Zeile unter "Spaeter, ohne
Datum" entfaellt, damit dasselbe Thema nicht zweimal steht.

"Rueckkaufswert am Vertrag" (1.1, #8) und "Ansprechpartner am Vorgang"
(1.2, #9) bleiben, wo sie stehen, sind aber als zugehoerig kenntlich
gemacht.

Refs #10
2026-08-23 01:52:09 +02:00
florian.wilde 8a594ee4a4 Merge #2: Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Behebt Issue #1: "Mein Konto" war am Schreibtisch praktisch nicht zu
finden, weil die Seitenleiste mit der Seite mitwuchs und ihr Fuss am
Seitenende landete.

Closes #1
2026-08-22 23:03:53 +00:00
Florian Wilde 0128bf71e7 Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Am Schreibtisch war "Mein Konto" praktisch nicht zu finden - und damit
auch nicht Passwortwechsel, zweiter Faktor und Passkey-Verwaltung.

Die Seitenleiste hatte keine feste Hoehe und wuchs mit der Seite mit. Ihr
Fuss mit dem Benutzermenue landete deshalb am Ende der SEITE statt am
unteren Bildschirmrand: auf /heute bei 1632 Pixel in einem 900 Pixel
hohen Fenster. Nachgemessen, nicht geraten - im Browser auf fuenf Seiten.

Der Fehler sah nach nichts aus. Kein Element fehlte, nichts war kaputt,
die Seite sah richtig aus. Man musste nur ganz nach unten scrollen.

Behoben mit lg:sticky, lg:top-0, lg:h-dvh und lg:self-start. Das
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert. Die
Navigation dazwischen hat bereits overflow-y-auto und scrollt bei Bedarf
in sich selbst.

Am Handy war es nie ein Problem - dort sitzt das Menue in der ohnehin
festen Kopfzeile.

Neu: e2e/benutzermenue.spec.ts fragt nicht, ob das Menue im DOM ist,
sondern ob es im sichtbaren Bereich steht - auf fuenf Seiten, an drei
Scrollpositionen, auf einem flachen Laptopfenster und am Handy. Die
Position in der Mitte ist Absicht: Am Seitenende wird eine mitwachsende
Leiste zufaellig doch sichtbar, der Test waere gruen und der Fehler
trotzdem da. Beide Sichtbarkeitstests sind gegen den kaputten Zustand
gegengeprueft und schlagen dort an.

Refs #1
2026-08-23 01:00:22 +02:00
Florian Wilde 38f4d8d91f fix: Skripte lagen ohne Ausfuehrungsrecht im Repository
Die Anleitung sagt "./scripts/update.sh" - auf Linux kam dort
"Permission denied". Die drei .sh-Dateien lagen mit Modus 644 statt 755
im Index.

Ursache ist der Entwicklungsrechner: Unter Windows steht
core.filemode=false, git nimmt ein fehlendes Ausfuehrungsrecht dort gar
nicht wahr. Gemeldet hat es der Betrieb, nicht der Bau.

Wer von 1.1.0 oder aelter kommt, braucht einmalig "bash
scripts/update.sh" - das alte Skript ist ja noch das nicht ausfuehrbare.
Steht jetzt in README und Installationsanleitung.

Zusaetzlich setzt update.sh nach dem Umschalten selbst ein chmod +x auf
scripts/*.sh. Das faengt den Fall ab, dass jemand Dateien von Hand
kopiert und das Recht dabei verliert - genau so kam man bisher an dieses
Skript, bevor es in der laufenden Fassung existierte.

Neu: tests/skripte.test.ts prueft nicht Verhalten, sondern Verpackung -
Ausfuehrungsrecht im Index, Shebang, scripts/ im Docker-Abbild, die
Sicherungsskripte in der Compose-Datei. Es ist der zweite Fehler dieser
Art (vorher fehlte scripts/ ganz im Abbild, 1.0.1); beide Male war der
Code richtig und kein Test sah hin, wie die Dateien beim Server
ankommen. Gegenprobe gemacht: mit zurueckgesetztem Recht schlaegt er
fehl und nennt die Datei.

Nebenbei aufgefallen: scripts/version.mjs datierte in UTC. Diese Fassung
waere um 00:08 deutscher Zeit auf den Vortag datiert worden. Rechnet
jetzt in Europe/Berlin.
2026-08-23 00:13:05 +02:00
Florian Wilde c38c988c3c feat: Anmelden mit Passkey
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".

Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.

TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.

Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
  nicht in der Server Action davor. Ein "war schon geprueft" ueber die
  Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
  lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
  verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
  zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
  Anmeldung nennt den verwendeten Passkey namentlich.

Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
  prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
  entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
  Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
  Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
  geloeschte.

Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.

Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
2026-08-22 18:44:56 +02:00
Florian Wilde 9603917a86 fix: Changelog aus der Zielfassung, blockierende Dateien beiseite
Zwei Fehler beim ersten Probelauf des Update-Skripts.

Der Changelog blieb leer. Das Skript las CHANGELOG.md aus der Arbeitskopie
- also aus dem LAUFENDEN Stand, der die neuen Abschnitte naturgemaess noch
nicht kennt. Jetzt kommt er ueber git show aus der Zielfassung.

Das Umschalten scheiterte an einer unversionierten Datei. Genau das
passiert beim Sprung auf die Fassung, die dieses Skript eingefuehrt hat:
Man holt es sich von Hand, und in der Zielfassung gehoert es dann dazu -
git weigert sich, eine unversionierte Datei zu ueberschreiben. Solche
Dateien werden jetzt nicht weggeworfen, sondern nach .vor-update-<zeit>
beiseite geschoben, danach laeuft das Umschalten durch.

Etappe: 8
Phase: Haertung
2026-08-22 15:10:40 +02:00
Florian Wilde 42eb242767 feat: Update-Skript, das die vergessenen Schritte mitnimmt
Bisher war Aktualisieren eine Folge von Befehlen aus der Anleitung, und
zwei davon vergisst man zuverlaessig: die Sicherung vorher und die beiden
SQL-Skripte hinterher. Genau die hat es hier schon gekostet.

./scripts/update.sh macht das in einem Durchgang - mit zwei
Eigenschaften, auf die es ankommt:

Bis zur Rueckfrage wird nichts veraendert. Der erste Entwurf hat die
eigenen Aenderungen beiseite gelegt und danach am Serverzugriff
abgebrochen - Halbzustand, aus dem man erst wieder herausfinden muss.
Jetzt laufen Pruefung, Abgleich und Changelog-Anzeige vollstaendig
durch, bevor irgendetwas angefasst wird.

Eigene Aenderungen an versionierten Dateien blockieren nicht. Im Betrieb
passt man die docker-compose.yml an, bis etwas laeuft; genau diese
Anpassungen sind spaeter oft schon eingearbeitet. Das Skript legt sie mit
git stash beiseite - rueckholbar, nicht weggeworfen - und sagt, wie man
sie zurueckbekommt. Wer sie wirklich loswerden will, nimmt --verwerfen.
Die .env bleibt immer unangetastet.

Schlaegt die Sicherung fehl, wird nicht aktualisiert. Schlaegt der Bau
fehl, nennt das Skript den Weg zurueck.

Nebenbei behoben: Der Port der MinIO-Konsole stand fest auf 9001. Lief
dort schon etwas, startete der ganze Stapel nicht. Jetzt ueber
MINIO_KONSOLE_PORT einstellbar - dieselbe Moeglichkeit, die APP_PORT
schon hatte. Aufgefallen beim Aufsetzen einer Testumgebung.

Etappe: 8
Phase: Haertung
2026-08-22 15:09:01 +02:00
Florian Wilde 7780739a1a fix: Ersteinrichtung liess sich nicht abschliessen, Betreute nicht loeschen
Zwei Fehler, beide erst im Betrieb sichtbar.

1. Schritt 3 der Ersteinrichtung meldete "Bitte pruefe die markierten
   Felder" - und markierte keines. Man kam dort nicht weiter.

   Das kurze Formular schickt neun Felder, das vollstaendige unter
   Stammdaten alle. Beide gehen durch dasselbe Schema, und die optionalen
   Felder waren zwar nullable, aber nicht optional: Ein Feld, das gar
   nicht abgeschickt wurde, ist undefined, nicht null. Beanstandet wurden
   also zwei Dutzend Felder, die auf dem Bildschirm nie standen.

   Damit das nicht wiederkommt, unterscheidet die Aktion jetzt zwischen
   Eingabefehler und Formular-Schema-Bruch: Beanstandet die Pruefung ein
   Feld, das nicht abgeschickt wurde, sagt sie das ausdruecklich und
   schreibt es ins Serverlog, statt es dem Nutzer als Eingabefehler
   unterzuschieben, den er nirgends finden kann.

2. Eine betreute Person liess sich nicht endgueltig loeschen. Postgres
   setzt beim Loeschen den Verweis im Audit-Log auf NULL, und genau
   dieses UPDATE hat der Unveraenderlichkeits-Trigger abgewiesen. Damit
   scheiterte auch das Leeren des Papierkorbs nach 30 Tagen.

   Der Trigger erlaubt jetzt genau eine Ausnahme: das Nullen der beiden
   Fremdschluessel. Jede andere Aenderung bleibt verboten, auch das
   Setzen eines Fremdschluessels auf einen anderen Wert. Der Nachweis
   bleibt vollstaendig - es verschwindet nur der Verweis auf einen
   Datensatz, den es nicht mehr gibt.

Geprueft: sieben neue Tests gegen die tatsaechlichen Formularinhalte, der
Ablauf einmal komplett im Browser bis zum Dashboard, und der Trigger
gegen eine laufende Datenbank - Loeschen gelingt, Faelschen scheitert.

Etappe: 8
Phase: Haertung
2026-08-22 14:36:18 +02:00
189 changed files with 26468 additions and 9939 deletions
+33 -2
View File
@@ -15,9 +15,17 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel # 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel
AUTH_SECRET= AUTH_SECRET=
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). # Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
# Hinter einem Proxy die Adresse, die im Browser in der Adresszeile steht -
# nicht die interne. Passkeys pruefen dagegen.
AUTH_URL=http://localhost:3000 AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true # Darf der Host-Kopf der Anfrage die Adresse bestimmen?
#
# Solange AUTH_URL gesetzt ist: nein, und die Zeile bleibt weg. Die Adresse
# ist dann bekannt und muss nicht aus einer Kopfzeile abgeleitet werden, die
# von aussen kommt. Ein "X-Forwarded-Host: fremde.example" schlaegt sonst in
# erzeugte Adressen durch.
# AUTH_TRUST_HOST=true
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN ------------------------- # --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
# #
@@ -47,6 +55,10 @@ AUTH_TRUST_HOST=true
APP_BIND=127.0.0.1 APP_BIND=127.0.0.1
APP_PORT=3000 APP_PORT=3000
# Die Konsole des mitgelieferten Objektspeichers. Nur noetig, wenn auf dem
# Server schon etwas auf 9001 laeuft.
MINIO_KONSOLE_PORT=9001
# --- Anwendungsseitige Feldverschluesselung --------------------------------- # --- Anwendungsseitige Feldverschluesselung ---------------------------------
# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN # AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN
# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern! # FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern!
@@ -69,6 +81,25 @@ LOGIN_MAX_VERSUCHE=5
LOGIN_FENSTER_SEKUNDEN=900 LOGIN_FENSTER_SEKUNDEN=900
LOGIN_SPERRE_SEKUNDEN=900 LOGIN_SPERRE_SEKUNDEN=900
# Geheimnis fuer die Ersteinrichtung. OHNE DIESEN WERT LAESST SICH DER ERSTE
# ZUGANG NICHT ANLEGEN - das ist Absicht.
#
# Zwischen dem ersten Start und dem Anlegen des Zugangs stand die
# Einrichtungsseite sonst offen: Wer sie in diesem Fenster findet, wird
# Administrator. Erzeugen: npm run schluessel
ERSTEINRICHTUNG_GEHEIMNIS=
# Steht ein Reverse Proxy davor?
#
# Nur mit "true" wird "X-Forwarded-For" geglaubt - und dann von HINTEN
# gelesen, weil der vordere Teil der Liste vom Aufrufer selbst kommt.
# Ohne diese Zeile zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
#
# Der dokumentierte Betrieb (Reverse Proxy davor) braucht sie also:
VERTRAUE_PROXY=true
# Bei mehreren gestaffelten Proxys deren Anzahl. Sonst weglassen.
# VERTRAUE_PROXY_STUFEN=1
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------ # --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein. # Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen. # ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
+32
View File
@@ -0,0 +1,32 @@
# Zeilenenden
#
# Im Repository steht ueberall LF, unabhaengig davon, auf welchem System
# gearbeitet wird. Der Grund ist nicht Geschmack, sondern eine Falle:
#
# Solange Dateien gemischt vorliegen, schreibt jedes zeilenweise
# arbeitende Werkzeug - sed, ein Formatierer, ein Skript - die Datei mit
# LF zurueck. Der Diff zeigt dann nicht die drei geaenderten Zeilen,
# sondern die ganze Datei. Genau das ist passiert: 578 Diff-Zeilen statt
# 8, kurz vor einem Push bemerkt.
#
# Nebenwirkung derselben Mischung: "git diff --check" meldet bei
# CRLF-Dateien jede hinzugefuegte Zeile als "trailing whitespace", weil
# es das CR so wertet. Damit ist die Pruefung fuer diese Dateien wertlos.
* text=auto eol=lf
# Shell-Skripte laufen auf dem Server. Ein CRLF im Shebang macht sie
# unbrauchbar ("bad interpreter"), und der Fehler ist von aussen nicht
# anzusehen. Deshalb hier ausdruecklich, nicht nur ueber die Regel oben.
*.sh text eol=lf
# Binaerdateien nie anfassen.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.zip binary
+4
View File
@@ -43,3 +43,7 @@ npm-debug.log*
# Bildschirmfotos entstehen beim Entwickeln neu # Bildschirmfotos entstehen beim Entwickeln neu
screenshots/ screenshots/
daten/dokumente/ daten/dokumente/
# Arbeitsbereiche paralleler Agenten (git worktrees). Enthalten einen
# vollstaendigen zweiten Checkout - der gehoert nicht ins Repository.
.claude/
+987 -1
View File
File diff suppressed because it is too large Load Diff
+75 -16
View File
@@ -97,11 +97,12 @@ DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit
**3. Datenbank einrichten:** **3. Datenbank einrichten:**
```bash ```bash
npx prisma migrate dev --name fundament npm run setup # Migrationen, Volltextsuche, Audit-Schutz, Beispieldaten
npm run db:sql # Volltextsuche und Audit-Schutz
npm run db:seed
``` ```
Das ist die Kurzform für `db:deploy`, `db:sql` und `db:seed` — die drei
Schritte laufen ohne Rückfrage durch.
**4. Starten:** **4. Starten:**
```bash ```bash
@@ -177,12 +178,28 @@ existiert.
### Aktualisieren ### Aktualisieren
```bash ```bash
git pull ./scripts/update.sh
docker compose up -d --build
``` ```
Die Migrationen laufen beim Start des Anwendungsbehälters mit. **Vorher ein > **Kommst du von 1.1.0 oder älter?** Dort lag `update.sh` ohne
Backup anstoßen** (siehe unten) — eine Migration lässt sich nicht zurückrollen. > Ausführungsrecht im Repository — `./scripts/update.sh` endet dann in
> „Permission denied". Einmalig hilft:
>
> ```bash
> bash scripts/update.sh
> ```
>
> Ab 1.1.1 ist das Recht gesetzt und der Punktschrägstrich funktioniert.
Das Skript sichert zuerst, zeigt den Changelog zwischen altem und neuem
Stand, fragt nach, schaltet um, baut neu und führt danach die beiden
SQL-Skripte aus. Schlägt die Sicherung fehl, wird nicht aktualisiert.
`--pruefen` sieht nur nach, ohne etwas zu ändern. Eigene Änderungen an
versionierten Dateien werden vor dem Umschalten beiseite gelegt
(`git stash`), die `.env` bleibt unangetastet.
Einzelheiten und der Weg von Hand: [`docs/INSTALLATION.md`](docs/INSTALLATION.md).
--- ---
@@ -287,6 +304,15 @@ entscheiden kann. Was dafür umgesetzt ist:
- Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung). - Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung).
- **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach - **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach
RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password. RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password.
- **Passkeys** (WebAuthn/FIDO2) als zweiter Anmeldeweg — auch für die
erneute Bestätigung vor sensiblen Aktionen. Sie *ersetzen* den zweiten
Faktor, statt zu ihm hinzuzukommen: Nutzerverifikation wird verlangt
(`userVerification: 'required'`) und serverseitig nachgeprüft, ein
Schlüssel ohne sie wird abgewiesen. Damit sind es dieselben zwei
Faktoren — Besitz des Geräts und dessen Entsperrung —, nur an die Domain
gebunden und deshalb nicht abphishbar. TOTP muss trotzdem eingerichtet
sein, bevor sich ein Passkey anlegen lässt: Er hängt an einem Gerät und
darf nicht der einzige Weg hinein sein.
- **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre - **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre
wird geprüft, bevor Argon2 überhaupt rechnet. wird geprüft, bevor Argon2 überhaupt rechnet.
- Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht - Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht
@@ -488,6 +514,35 @@ npm run protokoll # Bauprotokoll aus der Git-Historie
npm run version -- patch # Version anheben, Changelog vorbereiten npm run version -- patch # Version anheben, Changelog vorbereiten
``` ```
### Die Datenbankbefehle
Welcher Befehl wofür — und welcher am Terminal Rückfragen stellt. Nur
`db:migrate` ist interaktiv; alle anderen laufen in Skripten, in der CI
und im Container durch.
| Befehl | Wofür | Rückfragen? |
|---|---|---|
| `npm run setup` | Ersteinrichtung: Migrationen, SQL-Skripte, Beispieldaten | nein |
| `npm run db:migration -- <name>` | Schemaänderung während der Entwicklung — erzeugt die Migration und nimmt die `DROP INDEX`-Zeilen zur Volltextsuche heraus | nein |
| `npm run db:deploy` | Vorhandene Migrationen anwenden (Server, CI, Container) | nein |
| `npm run db:migrate` | `prisma migrate dev` — fragt nach dem Namen und bietet an, die Datenbank zurückzusetzen | **ja** |
| `npm run db:sql` | Volltextsuche und Audit-Schutz; läuft nicht mit den Migrationen mit | nein |
| `npm run db:pruefen` | Nachsehen, ob die Trigramm-Indizes noch stehen | nein |
| `npm run db:seed` | Beispieldaten einspielen | nein |
Der übliche Weg für eine Schemaänderung:
```bash
# Schema in prisma/schema.prisma ändern, dann
npm run db:migration -- ansprechpartner # erzeugen und lesen
npm run db:deploy # anwenden
npm run db:pruefen # Indizes nachsehen
```
`db:migrate` braucht man dafür nicht mehr. Es bleibt für die Fälle, in
denen man wirklich den interaktiven Prisma-Ablauf will — etwa um eine
verkorkste Entwicklungsdatenbank zurückzusetzen.
Konventionen — verbindlich für alle Beiträge: Konventionen — verbindlich für alle Beiträge:
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md). [`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
Schnittstellen zwischen den Modulen: Schnittstellen zwischen den Modulen:
@@ -520,7 +575,8 @@ Produktion.
über den Abschnitt `environment:`. über den Abschnitt `environment:`.
- Beim ersten Start eines Containers laufen die Migrationen automatisch - Beim ersten Start eines Containers laufen die Migrationen automatisch
mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind
**einmalig von Hand** anzustoßen, siehe oben. **einmalig von Hand** anzustoßen, siehe oben. Beim Aktualisieren
erledigt das `scripts/update.sh` mit — beide Skripte sind idempotent.
### Technische Grundlage ### Technische Grundlage
@@ -553,14 +609,17 @@ Fehler, die während des Baus aufgefallen sind.
## Stand ## Stand
| | | | | | nachzuzählen mit |
|---|---:| |---|---:|---|
| Zeilen TypeScript/TSX | 71.051 | | Zeilen TypeScript/TSX | 79.102 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Prisma-Modelle | 24 | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
| Seiten | 46 | | Seiten | 82 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 458 | | Unit-Tests (Vitest) | 602 | `npm test` |
| End-to-End-Tests (Playwright) | 34 | | End-to-End-Tests (Playwright) | 87 | `npm run test:e2e` — 47 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
| TypeScript-Fehler | 0 | | TypeScript-Fehler | 0 | `npm run typecheck` |
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
jemandem auffällt — bei den Modellen und Seiten war genau das passiert.
Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den
fertigen Standalone-Server geprüft: strenge CSP aktiv, keine fertigen Standalone-Server geprüft: strenge CSP aktiv, keine
+5 -2
View File
@@ -66,7 +66,10 @@ services:
ports: ports:
# Nur an die Loopback-Adresse gebunden. Die Konsole erreichst du # Nur an die Loopback-Adresse gebunden. Die Konsole erreichst du
# ueber einen SSH-Tunnel oder ueber deinen vorhandenen Proxy. # ueber einen SSH-Tunnel oder ueber deinen vorhandenen Proxy.
- '127.0.0.1:9001:9001' #
# Laeuft auf dem Server schon etwas auf 9001, weich in der .env aus:
# MINIO_KONSOLE_PORT=9011
- '${MINIO_KONSOLE_BIND:-127.0.0.1}:${MINIO_KONSOLE_PORT:-9001}:9001'
networks: [intern] networks: [intern]
app: app:
@@ -93,7 +96,7 @@ services:
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar} ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt # Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts. # leere Schluesselvariablen - siehe src/lib/env.ts.
ENC_KEY_V2: ${ENC_KEY_V2:-} # ENC_KEY_V2: ${ENC_KEY_V2:-}
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1} ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env} INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800} SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800}
+84
View File
@@ -57,6 +57,10 @@ Anschreiben wieder auf.
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf **Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin. *Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
Und wenn du magst: Richte unter **Mein Konto → Passkeys** einen Passkey
ein. Dann meldest du dich künftig mit dem Finger an, statt drei Felder
auszufüllen. Wie das geht, steht gleich im nächsten Abschnitt.
--- ---
## Der Morgen: die Seite „Heute" ## Der Morgen: die Seite „Heute"
@@ -328,6 +332,86 @@ Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
--- ---
## Anmelden mit dem Finger: Passkeys
Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig —
und lästige Sicherheit wird umgangen. Ein Passkey macht daraus einen
Handgriff.
### Einrichten
**Mein Konto → Passkeys.** Gib dem Gerät einen Namen, an dem du es später
wiedererkennst („iPhone", „Laptop Büro"), und klicke auf *Passkey
einrichten*. Dein Gerät fragt nach Fingerabdruck, Gesicht oder PIN —
fertig.
Mach das auf **jedem Gerät**, mit dem du arbeitest. Sie stören sich nicht
gegenseitig.
### Anmelden
Auf der Anmeldeseite steht unter dem Formular *Mit Passkey anmelden*. Ein
Klick, ein Finger, drin. Keine E-Mail, kein Passwort, kein Code.
### Warum das nicht unsicherer ist
Es sieht nach weniger aus, ist aber dasselbe — zweimal:
| | Bisher | Mit Passkey |
|---|---|---|
| Erster Faktor | Passwort (Wissen) | Das Gerät selbst (Besitz) |
| Zweiter Faktor | Code aus der App (Besitz) | Fingerabdruck, Gesicht oder PIN |
Der entscheidende Zusatz: **Ein Passkey ist an die Adresse gebunden**,
unter der du ihn eingerichtet hast. Landest du auf einer nachgebauten
Anmeldeseite, funktioniert er dort schlicht nicht — er lässt sich nicht
abtippen und nicht weitergeben. Genau das ist der übliche Weg, an einen
Code aus der Authenticator-App zu kommen.
Ein Gerät, das den Passkey ohne Fingerabdruck oder PIN freigibt, wird
abgewiesen. Das wäre nur ein Faktor, und dafür ist hier zu viel zu
verlieren.
### Die Authenticator-App bleibt
Du kannst sie nicht abschaffen, und das ist Absicht. **Ein Passkey hängt
an einem Gerät.** Geht das Handy in der Donau baden und wäre der Passkey
der einzige Weg hinein, käme niemand mehr an die Akte — auch der Bezirk
nicht, auch das Gericht nicht.
Deshalb: erst Authenticator-App und Wiederherstellungscodes, dann Passkeys.
Die Anmeldung mit E-Mail, Passwort und Code bleibt daneben immer offen.
### Was in der Liste steht
Unter **Mein Konto → Passkeys** siehst du zu jedem Gerät, wann es
eingerichtet und wann es zuletzt benutzt wurde. **Sieh da gelegentlich
hinein.** Ein Eintrag, den du nicht zuordnen kannst, gehört sofort
entfernt — und danach das Passwort gewechselt.
Steht bei einem Eintrag *gesichert*, wird der Schlüssel vom Anbieter
mitgesichert (iCloud-Schlüsselbund, Google, Bitwarden) und taucht auf
einem neuen Gerät von selbst wieder auf. Ohne diesen Vermerk liegt er nur
auf diesem einen Gerät.
### Wenn ein Gerät verloren geht
1. Auf einem anderen Gerät mit E-Mail, Passwort und Code anmelden.
2. **Mein Konto → Passkeys**, den Eintrag des verlorenen Geräts entfernen.
3. Fertig — der Schlüssel auf dem Gerät ist damit wertlos.
Ein Passkey allein reicht ohnehin niemandem, der das Gerät findet: Ohne
Fingerabdruck oder PIN gibt das Gerät ihn nicht heraus.
### Wenn der Knopf gar nicht erscheint
Dann bietet der Server Passkeys nicht an — das Verfahren verlangt eine
verschlüsselte Verbindung (`https`). Oder der Browser ist zu alt. Melde
dich mit E-Mail, Passwort und Code an; alles andere funktioniert
unverändert.
---
## Am Handy ## Am Handy
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut, Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
+155 -19
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.0.0 git checkout v1.10.2
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.0.0 git describe --tags # v1.10.2
``` ```
--- ---
@@ -156,6 +156,7 @@ const w = n => b(n).replace(/[+/=]/g,'x');
console.log('AUTH_SECRET=' + b(32)); console.log('AUTH_SECRET=' + b(32));
console.log('ENC_KEY_V1=' + b(32)); console.log('ENC_KEY_V1=' + b(32));
console.log('INDEX_KEY=' + b(32)); console.log('INDEX_KEY=' + b(32));
console.log('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
console.log('POSTGRES_PASSWORD=' + w(24)); console.log('POSTGRES_PASSWORD=' + w(24));
console.log('SICHERUNG_PASSWORT=' + w(24)); console.log('SICHERUNG_PASSWORT=' + w(24));
console.log('S3_ACCESS_KEY_ID=' + w(12)); console.log('S3_ACCESS_KEY_ID=' + w(12));
@@ -192,7 +193,15 @@ POSTGRES_PASSWORD=<aus Schritt 2>
# --- Anmeldung --- # --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2> AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
AUTH_TRUST_HOST=true
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen. Absicht:
# sonst steht die Einrichtungsseite bis zum ersten Anlegen fuer jeden offen.
ERSTEINRICHTUNG_GEHEIMNIS=<aus Schritt 2>
# Ein Reverse Proxy steht davor (Schritt 7). Nur damit wird der
# weitergereichte Kopf geglaubt, aus dem die Anmeldesperre den Anschluss
# liest. Ohne die Zeile zaehlt sie nur nach E-Mail-Adresse.
VERTRAUE_PROXY=true
# --- Verschlüsselung --- # --- Verschlüsselung ---
ENC_KEY_V1=<aus Schritt 2> ENC_KEY_V1=<aus Schritt 2>
@@ -220,6 +229,12 @@ APP_PORT=3000
> Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche > Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular, > Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht. > ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
>
> Seit 1.1.0 hängt noch mehr daran: Aus dieser Adresse wird die Kennung
> abgeleitet, an die Passkeys gebunden werden. Steht sie falsch, lässt
> sich zwar ein Passkey einrichten, aber nie wieder verwenden. Und ein
> späterer Wechsel der Domain macht alle vorhandenen Passkeys
> unbrauchbar — das ist kein Fehler, sondern der Phishingschutz.
**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus: **Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus:
@@ -252,12 +267,16 @@ chmod 600 .env
```bash ```bash
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte. # Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \ grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ERSTEINRICHTUNG_GEHEIMNIS|VERTRAUE_PROXY|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
| sed 's/=.*/ = gesetzt/' | sed 's/=.*/ = gesetzt/'
``` ```
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht — Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche. und sagt dir beim Start auch, welche. Zwei Ausnahmen: Fehlt
`ERSTEINRICHTUNG_GEHEIMNIS`, startet die Anwendung, aber der erste Zugang
lässt sich nicht anlegen. Fehlt `VERTRAUE_PROXY`, läuft alles — die
Anmeldesperre zählt dann nur nach E-Mail-Adresse und schreibt beim ersten
Mal eine Zeile ins Log.
--- ---
@@ -293,7 +312,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0","commit":"..."} # {"status":"bereit","version":"1.10.2","commit":"..."}
``` ```
Kommt hier nichts, sieh in die Logs: Kommt hier nichts, sieh in die Logs:
@@ -643,7 +662,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0",...} # {"status":"bereit","version":"1.10.2",...}
``` ```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -694,7 +713,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.0.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.10.2 · <commit>`.
--- ---
@@ -832,27 +851,90 @@ Denk an die letzten Pflichten:
## Aktualisieren ## Aktualisieren
```bash
cd /opt/betreuungs-cockpit
./scripts/update.sh
```
> **Kommst du von 1.1.0 oder älter?** Dort lag `update.sh` ohne
> Ausführungsrecht im Repository — `./scripts/update.sh` endet dann in
> „Permission denied". Einmalig hilft:
>
> ```bash
> bash scripts/update.sh
> ```
>
> Ab 1.1.1 ist das Recht gesetzt und der Punktschrägstrich funktioniert.
Das war es. Das Skript nimmt dir die Schritte ab, die man von Hand
vergisst:
1. Prüft Verzeichnis, git, docker und `.env`
2. Holt die Fassungen und zeigt, was zwischen deinem Stand und dem neuen
im Changelog steht
3. Fragt nach
4. **Stößt eine Sicherung an** — und bricht ab, wenn sie nicht klappt
5. Legt eigene Änderungen an versionierten Dateien beiseite, schaltet um,
baut neu
6. Wartet, bis die Anwendung antwortet
7. Führt die **beiden SQL-Skripte** aus (Volltextsuche, Audit-Schutz)
8. Prüft, ob wirklich die neue Fassung läuft
**Bis Schritt 3 wird nichts verändert.** Bricht etwas vorher ab, findest du
alles so vor wie zuvor.
### Aufrufmöglichkeiten
| Aufruf | Wirkung |
|---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.10.2` | 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 |
| `./scripts/update.sh --ohne-sicherung` | Sicherung überspringen — sehr gut überlegen |
### Eigene Änderungen an der docker-compose.yml
Das ist der Normalfall: Man passt etwas an, bis es läuft. Das Skript legt
solche Änderungen vor dem Umschalten mit `git stash` beiseite und sagt,
wie man sie zurückholt:
```bash
git stash list # was liegt da
git stash pop # zurückholen
git stash drop # wegwerfen
```
Sind deine Anpassungen inzwischen im Projekt eingearbeitet — wie
`APP_BIND` oder `MINIO_KONSOLE_PORT` —, brauchst du sie nicht
zurückzuholen. Dann setz die Werte einfach in der `.env`.
> **Die `.env` wird nie angefasst.** Sie ist nicht versioniert; weder
> `git checkout` noch das Skript rühren sie an.
### Von Hand, wenn du lieber selbst steuerst
```bash ```bash
cd /opt/betreuungs-cockpit cd /opt/betreuungs-cockpit
# 1. IMMER zuerst sichern - eine Migration lässt sich nicht zurückrollen # 1. IMMER zuerst sichern - eine Migration lässt sich nicht zurückrollen
docker compose exec sicherung /bin/sh /sicherung.sh docker compose exec sicherung /bin/sh /sicherung.sh
# 2. Was ändert sich? Lies den Changelog, bevor du ziehst # 2. Was ändert sich?
git fetch --tags git fetch --tags
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/main
# 3. Auf die neue Fassung wechseln # 3. Umschalten
git checkout v1.1.0 git checkout v1.10.2
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) \ GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
BUILD_ZEIT=$(date -Iseconds) \
docker compose up -d --build
```
Die Migrationen laufen beim Start mit. **Sagt der Changelog, dass sich ein # 5. Die beiden SQL-Skripte - sie laufen NICHT mit den Migrationen mit
SQL-Skript geändert hat, führ die beiden Befehle aus Schritt 5 erneut aus.** docker compose exec app node node_modules/prisma/build/index.js db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql
docker compose exec app node node_modules/prisma/build/index.js db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql
```
### Kontrolle ### Kontrolle
@@ -862,7 +944,21 @@ curl -s https://cockpit.deine-domain.de/api/gesundheit
``` ```
Steht dort noch die alte Version, läuft der alte Behälter weiter — dann Steht dort noch die alte Version, läuft der alte Behälter weiter — dann
hat der Bau nicht durchgezogen. Sieh in `docker compose logs app`. hat der Bau nicht durchgezogen:
```bash
docker compose up -d --build --force-recreate app
```
### Zurück auf den vorherigen Stand
```bash
git checkout v1.0.4
docker compose up -d --build
```
Hat die neue Fassung die Datenbank migriert, reicht das nicht — dann muss
auch die Sicherung zurück (siehe Schritt 9).
--- ---
@@ -957,6 +1053,46 @@ timedatectl
docker compose exec app date docker compose exec app date
``` ```
### Der Knopf „Mit Passkey anmelden" erscheint nicht
Passkeys brauchen einen sicheren Kontext. Die Anwendung bietet sie nur an,
wenn `AUTH_URL` auf `https://` steht (oder auf `localhost`) — sonst
erscheint der Knopf bewusst gar nicht erst, statt beim Klick mit einer
kryptischen Browsermeldung abzubrechen.
```bash
docker compose exec app printenv AUTH_URL # muss mit https:// beginnen
```
Steht dort `http://`, korrigiere die `.env` und starte neu:
```bash
docker compose up -d
```
Erscheint der Knopf weiterhin nicht, kann der Browser kein WebAuthn — das
betrifft praktisch nur sehr alte Fassungen.
### Ein Passkey lässt sich einrichten, aber nicht zum Anmelden benutzen
Fast immer stimmt die Adresse nicht überein. Der Schlüssel ist an den
Hostnamen aus `AUTH_URL` gebunden; der Browser vergleicht ihn mit dem, was
in der Adresszeile steht.
```bash
docker compose exec app printenv AUTH_URL
# muss exakt die Adresse sein, die du im Browser eintippst
```
Typische Ursachen: `AUTH_URL` zeigt auf `cockpit.example.de`, aufgerufen
wird aber `www.cockpit.example.de` — oder umgekehrt. Ein Passkey für die
eine Adresse gilt auf der anderen nicht.
**Nach einem Wechsel der Domain** müssen alle Passkeys neu eingerichtet
werden. Die alten Einträge lassen sich unter *Mein Konto → Passkeys*
entfernen. Die Anmeldung mit E-Mail, Passwort und Code funktioniert in der
Zwischenzeit unverändert — man sperrt sich damit also nicht aus.
### Die Texterkennung findet nichts ### Die Texterkennung findet nichts
```bash ```bash
+141 -5
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 Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben. als wichtiger herausstellt, rutscht nach oben.
Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.10.2** ([CHANGELOG](../CHANGELOG.md))
--- ---
@@ -61,9 +61,10 @@ Auszug erzeugt dann doppelte Buchungen.
Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht
deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem
Versicherer braucht. Versicherer braucht. Es ist derselbe Wert, um den es im Anschreiben an den
Versicherer geht — siehe 1.2a.
*Aufwand: klein.* *Aufwand: klein. Issue: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8).*
### Genau eine Zielspalte je Dokumentverknüpfung ### Genau eine Zielspalte je Dokumentverknüpfung
@@ -120,7 +121,10 @@ Benutzer. Bei einer Betreuung zu zweit ist das die erste Frage.
### Ansprechpartner am Vorgang ### Ansprechpartner am Vorgang
Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner
mangels Feld aber nicht übergeben. mangels Feld aber nicht übergeben. Gehört zum Schriftverkehr in 1.2a: dort
entsteht das Schreiben, hier fehlt ihm die Anrede.
*Issue: [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
### Inventarliste als eigenes Modell ### Inventarliste als eigenes Modell
@@ -136,6 +140,131 @@ Jahresbericht und bei einer Höherstufung gebraucht.
--- ---
## 1.2a — Schriftverkehr, der sich nicht wiederholt
**Ziel:** Ein Schreiben wird einmal geschrieben, einmal ausgegeben und
einmal abgelegt — statt viermal angefasst.
Die Anschreiben an Versicherer und Behörden gibt es heute nur als Text zum
Markieren und Kopieren. Was danach kommt, macht man von Hand: in Word
nachbauen, ausdrucken, abschicken und anschließend noch einmal als Dokument
in die Akte legen. Vergessen wird regelmäßig der letzte Schritt — und dann
fehlt der Nachweis genau dort, wo er später gebraucht wird.
Nach der Priorisierungsregel am Ende dieser Datei ist das Kriterium 2: Es
kostet jeden Monat Zeit, und einmal im Jahr — vor Vermögensverzeichnis und
Jahresbericht — besonders viel. Bisher stand das Thema als einzelne Zeile
unter „Später, ohne Datum", also unter „wäre schön, und ehrlich gesagt
vielleicht nie". Das war zu tief gehängt.
Die Punkte bauen aufeinander auf und stehen deshalb in dieser Reihenfolge.
### Verträge überhaupt erst finden
Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt; es liegt
kein Ordner bereit, und die betreute Person kann es oft selbst nicht mehr
sagen. Die Antwort steht aber schon im System: **Wer regelmäßig abbucht,
hat einen Vertrag.** Eine Auswertung der Gegenstellen nach wiederkehrenden
Zahlungen macht aus hunderten Buchungszeilen eine Liste von Gesellschaften,
an die zu schreiben ist. Solange die fehlt, nützt die beste Briefvorlage
nichts — deshalb steht dieser Punkt vor allen anderen.
*Issue: [#3](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/3).*
### Schreiben erzeugen, ablegen, nachhalten
Das Kernstück: eine Word-Datei statt Text zum Kopieren, Ablage als
Ausgangsdokument mit Empfänger und Versanddatum, Wiedervorlage mit
vorbelegter Frist. Dazu Empfänger, zu denen es noch keinen Datensatz gibt —
am Anfang schreibt man ja gerade an Stellen, von denen man erst erfahren
will, ob dort etwas besteht. Der Text bleibt vor der Ausgabe änderbar, weil
jeder Fall eine Besonderheit hat, die in den Brief gehört. Die Bausteine
liegen bereits im Code; was fehlt, ist der durchgehende Weg. Alles Weitere
in diesem Abschnitt setzt darauf auf.
*Issue: [#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4).*
### Dasselbe Schreiben als E-Mail
Vieles geht per Mail raus, nicht per Brief. Dafür müssen Betreff und Text
getrennt vorliegen und Absenderblock, Anschrift und Datumszeile
verschwinden. Auseinandergebaut wird das heute von Hand, und dabei fällt
regelmäßig etwas heraus, das drinbleiben sollte.
*Issue: [#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5).*
### Rückkaufswerte in einem Zug anfragen
Vor Vermögensverzeichnis und Jahresbericht sind es jedes Mal dieselben fünf
bis zehn Briefe an dieselben Gesellschaften — einzeln erzeugt, einzeln
nachgehalten, und bei zwei Dritteln muss nachgefasst werden. Das ist der
Handgriff, der im Jahr am meisten Zeit kostet, und derjenige, der sich am
einfachsten bündeln lässt, sobald ein Schreiben ohnehin auf Knopfdruck
entsteht.
*Issue: [#6](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/6).*
### Eigene Vorlagen
Die Brieftexte stehen fest im Programmcode. Formulierungen, die sich bei
einer bestimmten Stelle bewährt haben, lassen sich nicht hinterlegen, und
für Schreiben ohne mitgelieferte Vorlage — Auskunftsersuchen,
Vollmachtsnachweis, Adressänderung — fängt man bei null an. Wer eine
Vorlage selbst schreibt, vergisst als Erstes den Bestellungsabsatz; ohne
den antwortet keine Versicherung, und dann ist ein Monat vergangen. Die
Vorlagenverwaltung muss ihn deshalb einfordern, statt ihn nur anzubieten.
*Issue: [#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7).*
### Dauerzahlungen gelten als belegt
Ist ein Vertrag erst erfasst, ist er auch der Beleg für die Zahlungen
daraus. Für zwölf gleiche Lastschriften will niemand zwölf Quittungen
sehen, sondern die Police. Heute bleibt dafür nur ein Freitextfeld je
Buchung — stumpfe Arbeit mit einem Ergebnis, das schwächer aussieht als
die Aktenlage tatsächlich ist. Das Feld gehört in dieselbe Migration wie
`Buchung.immobilieId` aus 1.1; zwei Wanderungen an derselben Tabelle wären
Verschwendung.
*Issue: [#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11). Migration: ein Feld, ein Index — gebündelt mit 1.1.*
### Was in 1.1 und 1.2 dazugehört
Zwei Punkte weiter oben sind Teil derselben Sache und bleiben trotzdem
stehen, wo sie stehen: „Rückkaufswert am Vertrag" ist ein möglicher
Fehler und deshalb 1.1, „Ansprechpartner am Vorgang" ein Handgriff und
deshalb 1.2. Gelesen gehören sie hierher — der eine liefert den Wert, um
den das Anschreiben bittet, der andere die namentliche Anrede.
*Issues: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8) und [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
---
## 1.2b — Passkeys abrunden
Der Grundstock steht seit 1.1.0. Was noch fehlt, ist Bequemlichkeit, nicht
Sicherheit.
### Passkey direkt im Anmeldefeld anbieten
Browser können einen Passkey im Ausfüllvorschlag des E-Mail-Feldes
anbieten (*conditional UI*), statt hinter einem eigenen Knopf. Ein Schritt
weniger. Braucht einen zweiten Challenge-Weg und sorgfältiges Abbrechen,
wenn stattdessen doch das Passwort getippt wird.
### Passkey umbenennen
Heute geht nur anlegen und entfernen. Wer sein Gerät „Handy" genannt hat
und inzwischen zwei hat, muss den Passkey neu einrichten.
### Warnen, wenn der letzte Passkey nur auf einem Gerät liegt
Die Liste sagt bereits, ob ein Schlüssel gesichert ist. Wer ausschließlich
ungesicherte Passkeys hat und dazu keine Wiederherstellungscodes mehr,
sollte das deutlicher zu sehen bekommen als in einer Zeile Kleingedrucktem.
---
## 1.3 — Belastbarkeit ## 1.3 — Belastbarkeit
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen **Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
@@ -164,6 +293,14 @@ Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
Absichtserklärung. Absichtserklärung.
### Passkey-Ablehnung ohne Browser nachstellen
Die Prüfung „Passkey ohne Nutzerverifikation wird abgewiesen" ist im Code
doppelt abgesichert und im Browsertest belegt — dort weist allerdings
schon der Browser ab, der Server kommt gar nicht dran. Ein Test, der die
Antwort von Hand baut und den Browser umgeht, würde den serverseitigen
Rückhalt tatsächlich beweisen.
### Barrierefreiheits-Durchgang mit einem Screenreader ### Barrierefreiheits-Durchgang mit einem Screenreader
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
@@ -221,7 +358,6 @@ Sinnvoll, aber nicht dringend:
- **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt - **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt
einzelner Erinnerungen. einzelner Erinnerungen.
- **Vorlagen für eigene Anschreiben**, über die mitgelieferten hinaus.
- **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF. - **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF.
- **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug). - **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug).
- **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch - **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch
+168
View File
@@ -0,0 +1,168 @@
# Sicherheit
Was in dieser Anwendung gegen welche Bedrohung steht — und was
ausdrücklich **nicht**. Der zweite Teil ist der wichtigere: Eine Zusage,
die nicht gilt, ist gefährlicher als eine fehlende, weil man sich auf sie
verlässt.
Stand: v1.11.0. Die Fassung entstand beim Abarbeiten von Issue #38
(Sicherheitsprüfung durch ein zweites Modell) und dem eigenen Durchgang
entlang der OWASP Top 10 (2021).
---
## Das Bedrohungsmodell
Eine ehrenamtliche rechtliche Betreuung. Ein Benutzer, eine betreute
Person, ein kleiner Server hinter einem Reverse Proxy. In der Datenbank
liegen Gesundheitsdaten (Art. 9 DSGVO), Kontobewegungen und
Vermögensverzeichnisse.
Nach Wahrscheinlichkeit geordnet:
| Was | Wie wahrscheinlich | Was dagegen steht |
|---|---|---|
| Offene Sitzung an einem unbeaufsichtigten Rechner | hoch | Abmeldung nach 30 min Leerlauf; erneute Bestätigung vor Export, Löschen, Benutzer- und Passkey-Verwaltung |
| Verlorenes oder gestohlenes Gerät | mittel | Zweiter Faktor ist Pflicht; Passkeys verlangen Nutzerverifikation |
| Kopiertes Backup, falsch gerechtetes Verzeichnis | mittel | Feldverschlüsselung (AES-256-GCM), Dateien im Umschlag, Schlüssel nur in der Umgebung |
| Zugangsdaten zum Objektspeicher abhandengekommen | mittel | Objekte liegen verschlüsselt, an ihren Ablageort gebunden |
| Raten von Passwörtern über das Formular | mittel | Anmeldesperre nach E-Mail **und** Anschluss, Argon2id |
| Einbruch in den Server selbst | gering | Kaum etwas — wer dort Wurzelrechte hat, hat die Schlüssel |
Der letzte Punkt ist keine Nachlässigkeit, sondern eine Grenze: Die
Anwendung braucht die Schlüssel zur Laufzeit. Wer den Prozess kontrolliert,
kommt an sie heran. Dagegen hilft Betriebssicherheit, kein Anwendungscode.
---
## Was ausdrücklich nicht geschützt ist
- **Der Server selbst.** Siehe oben.
- **Metadaten in der Datenbank.** Verschlüsselt sind einzelne Felder —
Diagnosen, IBANs, Verwendungszwecke, Notizen. Dass es einen Vorgang
„Betreuungsgericht" gibt und wann er angelegt wurde, steht im Klartext.
Sonst ließe sich nichts mehr sortieren oder filtern.
- **Symlinks im Ablageverzeichnis.** `resolve()` folgt keinen Links. Wer
dort einen anlegen kann, hat bereits Schreibrechte im Datenverzeichnis —
dann ist der Umweg über einen Link die geringste Sorge. Bis v1.10.2 stand
im Code das Gegenteil; der Satz ist entfernt.
- **Die Middleware prüft keine Anmeldung.** Sie setzt Kopfzeilen, mehr
nicht. Der Zugriffsschutz sitzt in `src/lib/auth/sitzung.ts`, und nur
dort. Bis v1.10.2 behauptete ein Kommentar das Gegenteil.
- **Verfügbarkeit.** Es gibt keinen Schutz gegen jemanden, der den Server
mit Anfragen überzieht. Bei einem Nutzer hinter einem Proxy ist das die
Aufgabe des Proxys.
---
## Der Durchgang entlang der OWASP Top 10 (2021)
### A01 Broken Access Control
Jede Server Action und jede Seite geht durch `erfordereZugang()`, das
zeilenweise gegen die `betreuteId` prüft — nie nur im UI ausgeblendet.
Fremdschlüssel aus Formularen werden über `src/lib/auth/verweise.ts`
geprüft; ein verändertes Auswahlfeld kann keine fremde Akte verknüpfen
(e2e-Test `fremdakte.spec.ts`).
Weiterleitungsziele laufen durch `sicheresZiel()` — kein offener Redirect.
**Behoben in dieser Runde:** Die MFA-Pflicht galt nur für Seiten, nicht
für Server Actions und Route Handler. Sie steckt jetzt in
`erfordereBenutzer()` selbst. `tests/mfa-grenze.test.ts` liest den
Quelltext und schlägt an, sobald eine weitere Umgehung dazukommt.
### A02 Cryptographic Failures
- Felder: AES-256-GCM, Umschlag `bc1.<version>.<iv>.<tag>.<ct>`, Feldname
als Zusatzdaten — ein Geheimtext lässt sich nicht von einem Feld in ein
anderes kopieren.
- Dateien: AES-256-GCM, Umschlag `BCD1`, seit v1.11.0 **beide** Treiber,
mit dem Ablageschlüssel als Zusatzdaten.
- Passwörter: Argon2id, Parameter im Hash, Nachziehen beim Anmelden.
- Schlüsselrotation über `ENC_KEY_V<n>` / `ENC_KEY_AKTIV`; alte Schlüssel
bleiben zum Entschlüsseln stehen.
- Alle Geheimnisvergleiche laufen über `timingSafeEqual` — geprüft für
Reauth-Cookie, Zugriffstoken, ICS-Signatur, Wartungsschlüssel und das
Einrichtungsgeheimnis.
**Behoben in dieser Runde:** Der Objektspeicher verließ sich auf
`ServerSideEncryption: 'AES256'` — den Schlüssel des Anbieters.
### A03 Injection
Prisma parametrisiert. Die fünf Rohabfragen benutzen ausnahmslos
`Prisma.sql`-Templates; `Prisma.join()` in `suche.ts` fügt nur bereits
parametrisierte Fragmente zusammen und ist damit unbedenklich.
Zu XSS: `ts_headline` liefert `<mark>`-Marker im Suchausschnitt, und die
Quelle ist der OCR-Text hochgeladener Dokumente. Gerendert wird er in
`dokument-liste.tsx` aber nicht als HTML, sondern zerlegt und als React-
Kinder ausgegeben — also escaped. Das einzige
`dangerouslySetInnerHTML` der Anwendung setzt eine feste Konstante für die
Themenumschaltung. **Kein Fund.**
### A04 Insecure Design
Erneute Bestätigung vor den folgenschweren Handlungen. Anmeldesperre nach
zwei Dimensionen. Löschen ist ein Papierkorb mit 30 Tagen Frist.
**Behoben in dieser Runde:** Passkeys ließen sich ohne erneute
Bestätigung anlegen und entfernen — ein Passkey ist ein vollwertiger
Zugang, der einen Passwortwechsel überlebt.
### A05 Security Misconfiguration
CSP mit Nonce pro Anfrage und `strict-dynamic` im Produktivbetrieb; dazu
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy:
no-referrer`, `Permissions-Policy`, HSTS mit zwei Jahren.
`poweredByHeader` aus.
**Behoben in dieser Runde:** `trustHost` stand fest auf `true`, obwohl
`AUTH_URL` gesetzt ist. `X-Forwarded-For` wurde ohne Proxy geglaubt und
aus dem falschen Ende gelesen.
### A06 Vulnerable and Outdated Components
`npm audit`: 0 Schwachstellen (vorher 32, davon 5 kritisch).
### A07 Identification and Authentication Failures
Passwort + TOTP in einem Schritt, kein Zwischenzustand. Passkeys mit
erzwungener Nutzerverifikation zählen als zwei Faktoren.
`sitzungsGeneration` macht alle Sitzungen auf einen Schlag ungültig.
Antwortzeiten sind angeglichen, damit sich keine E-Mail-Adressen abfragen
lassen.
**Behoben in dieser Runde:** TOTP-Codes galten mehrfach; die Anmeldesperre
verlängerte sich selbst; die Ersteinrichtung stand offen und war nicht
atomar.
### A08 Software and Data Integrity Failures
Zu jedem abgelegten Dokument steht eine SHA-256-Prüfsumme über den
Klartext in der Datenbank — der Nachweis der Unverändertheit gegenüber dem
Gericht. Der Umschlag ist authentifiziert (GCM), Veränderungen fallen beim
Lesen auf.
### A09 Security Logging and Monitoring Failures
`AuditLog` hält Anmeldungen, Fehlversuche, Exporte, Löschungen und
Passkey-Änderungen fest. Anmeldeversuche stehen zusätzlich in
`login_versuch` und werden nach 30 Tagen aufgeräumt.
**Behoben in dieser Runde:** Während einer Sperre entstand pro Anfrage
eine Zeile — die Tabelle ließ sich vollschreiben. Jetzt eine je Sperre.
### A10 Server-Side Request Forgery
Die Anwendung ruft nichts von außen ab: kein `fetch`, keine
HTTP-Bibliothek, keine Webhooks, keine URL-Vorschauen. Ausgehend geht nur
SMTP an den konfigurierten Server. **Keine Angriffsfläche.**
---
## Wenn etwas gefunden wird
Kein öffentliches Issue. Der Weg führt über das Dashboard; dort wird
entschieden, ob und wie es hier landet.
+187 -187
View File
@@ -1,187 +1,187 @@
# Schema-Wünsche aus Etappe 3 „Papier“ # Schema-Wünsche aus Etappe 3 „Papier“
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
funktioniert, wäre aber sauberer, wenn es im Modell stünde. funktioniert, wäre aber sauberer, wenn es im Modell stünde.
Reihenfolge: was am meisten weh tut, steht oben. Reihenfolge: was am meisten weh tut, steht oben.
--- ---
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen ## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt **Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau „Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
Prüfung in `dokumentVerknuepfen`). Prüfung in `dokumentVerknuepfen`).
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein **Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
Seed oder ein späterer Import das nicht verletzen kann: Seed oder ein späterer Import das nicht verletzen kann:
```sql ```sql
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int + ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1 + ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
); );
``` ```
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
erklärbar. erklärbar.
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen **Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
WHERE "vorgangId" IS NOT NULL;`, würde das schließen. WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
--- ---
## 2. `Dokument.ocrStatus` ist ein freier String ## 2. `Dokument.ocrStatus` ist ein freier String
**Stand:** `ocrStatus String?` mit dem Kommentar **Stand:** `ocrStatus String?` mit dem Kommentar
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. `"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in **Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche `docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt (`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
heute erst auf, wenn das Abzeichen leer bleibt. heute erst auf, wenn das Abzeichen leer bleibt.
--- ---
## 3. Kein Index auf `Dokument.objektSchluessel` ## 3. Kein Index auf `Dokument.objektSchluessel`
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das **Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also **Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
--- ---
## 4. Keine Dublettenerkennung über die Prüfsumme ## 4. Keine Dublettenerkennung über die Prüfsumme
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer **Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload **Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
--- ---
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` ## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim **Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
Verknüpfen muss deshalb über die Beziehung gehen Verknüpfen muss deshalb über die Beziehung gehen
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). (`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen **Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
passieren. passieren.
--- ---
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel ## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein **Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
OCR-Lauf zuletzt versucht wurde, steht nirgends. OCR-Lauf zuletzt versucht wurde, steht nirgends.
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in **Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf `src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein `ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
bei jedem Nachziehen erneut durch Tesseract zu schicken. bei jedem Nachziehen erneut durch Tesseract zu schicken.
--- ---
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien ## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
festgehalten gehört. Bei `Dokument.ocrText` steht: festgehalten gehört. Bei `Dokument.ocrText` steht:
> „die Datei selbst liegt verschlüsselt im Objektspeicher“ > „die Datei selbst liegt verschlüsselt im Objektspeicher“
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für `ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — `signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet — Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos. sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()` Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt, würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
berührt. berührt.
--- ---
# Anhang: kein Schema-Wunsch, aber dringend # Anhang: kein Schema-Wunsch, aber dringend
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen ## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
also auch die Berichte aus Etappe 5. also auch die Berichte aus Etappe 5.
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit **Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
``` ```
TypeError: Cannot read properties of undefined (reading 'unitsPerEm') TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
at @react-pdf/textkit/lib/textkit.js:3090 at @react-pdf/textkit/lib/textkit.js:3090
``` ```
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist — auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
schuld: in reinem Node reproduziert es genauso. schuld: in reinem Node reproduziert es genauso.
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` **Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
inzwischen weitergewandert: inzwischen weitergewandert:
| Paket | installiert | von renderer 4.1.6 erwartet | | Paket | installiert | von renderer 4.1.6 erwartet |
|---|---|---| |---|---|---|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` | | `@react-pdf/font` | 3.1.0 | `^3.0.1` |
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | | `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) | | `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher `@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
löste die Schicht darunter den bloßen Namen selbst auf. löste die Schicht darunter den bloßen Namen selbst auf.
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit `@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen `layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
beiden klafft eine Lücke. beiden klafft eine Lücke.
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende **Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`). „Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier): **Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — `package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
```jsonc ```jsonc
"overrides": { "overrides": {
"@react-pdf/layout": "4.2.x", "@react-pdf/layout": "4.2.x",
"@react-pdf/textkit": "5.x" "@react-pdf/textkit": "5.x"
} }
``` ```
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen `@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
Unterabhängigkeiten wieder zusammenpassen. Danach kann Unterabhängigkeiten wieder zusammenpassen. Danach kann
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` `standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
+243 -243
View File
@@ -1,243 +1,243 @@
# Schema-Wünsche aus Etappe 5 „Behörde“ # Schema-Wünsche aus Etappe 5 „Behörde“
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
Behelf, der jetzt an seiner Stelle steht. Behelf, der jetzt an seiner Stelle steht.
--- ---
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig ## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
Sicht des Moduls, das die Gegenüberstellung baut. Sicht des Moduls, das die Gegenüberstellung baut.
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher **Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
Pflegegrad 3 um 1.319 Euro im Monat. Pflegegrad 3 um 1.319 Euro im Monat.
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart **Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in `EIGENANTEIL_NETTO` (siehe Kopfkommentar in
`src/lib/sozialhilfe/heimkosten.ts`): `src/lib/sozialhilfe/heimkosten.ts`):
``` ```
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung) Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
+ unterkunftVerpflegung + investitionskosten + unterkunftVerpflegung + investitionskosten
+ ausbildungsumlage + zusatzleistungen + ausbildungsumlage + zusatzleistungen
+ barbetrag + bekleidungspauschale + barbetrag + bekleidungspauschale
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
+ sozialhilfe + eigenmittel + sozialhilfe + eigenmittel
Deckungslücke = Bedarf − Deckung Deckungslücke = Bedarf − Deckung
``` ```
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als `pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich `pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor. Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der `leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
auf der Heimrechnung als eigene Abzugsposition. auf der Heimrechnung als eigene Abzugsposition.
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung `PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der
Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu
speichern. speichern.
**Wunsch, in dieser Reihenfolge:** **Wunsch, in dieser Reihenfolge:**
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und 1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
nicht nur hier: nicht nur hier:
```prisma ```prisma
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der /// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch /// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT /// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
/// als Deckung, sondern nur nachrichtlich. /// als Deckung, sondern nur nachrichtlich.
pflegeEigenanteilCent BigInt @default(0) pflegeEigenanteilCent BigInt @default(0)
``` ```
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes 2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
Modul raten, was in der Zeile steht: Modul raten, was in der Zeile steht:
```prisma ```prisma
enum Kostenlesart { enum Kostenlesart {
EIGENANTEIL_NETTO EIGENANTEIL_NETTO
PFLEGESATZ_BRUTTO PFLEGESATZ_BRUTTO
} }
lesart Kostenlesart @default(EIGENANTEIL_NETTO) lesart Kostenlesart @default(EIGENANTEIL_NETTO)
``` ```
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den 3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
die Summe könnte gegen sie geprüft werden. die Summe könnte gegen sie geprüft werden.
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul, **Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto- das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
Eigenanteil ein und lässt `pflegekasseCent` auf 0. Eigenanteil ein und lässt `pflegekasseCent` auf 0.
--- ---
## 2. `Nachforderung.checklistenSchluessel` — wichtig ## 2. `Nachforderung.checklistenSchluessel` — wichtig
```prisma ```prisma
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts. /// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen. /// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
checklistenSchluessel String? checklistenSchluessel String?
@@unique([antragId, checklistenSchluessel]) @@unique([antragId, checklistenSchluessel])
``` ```
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der **Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben, `nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es, getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
entsteht beim nächsten Übernehmen eine Dublette. entsteht beim nächsten Übernehmen eine Dublette.
**Behelf**: Vergleich über die Bezeichnung in **Behelf**: Vergleich über die Bezeichnung in
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`. `src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
--- ---
## 3. Rückkaufswert am Vertrag — wichtig ## 3. Rückkaufswert am Vertrag — wichtig
```prisma ```prisma
model Vertrag { model Vertrag {
// ... // ...
rueckkaufswertCent BigInt? rueckkaufswertCent BigInt?
rueckkaufswertStichtag DateTime? @db.Date rueckkaufswertStichtag DateTime? @db.Date
} }
``` ```
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge **Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`, nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die
Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
zusätzlich ein Vermögensposten angelegt wurde. zusätzlich ein Vermögensposten angelegt wurde.
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen. Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen **Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
Wert haben können (`MIT_RUECKKAUFSWERT` aus Wert haben können (`MIT_RUECKKAUFSWERT` aus
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht `src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der `Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt. Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
--- ---
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel ## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
```prisma ```prisma
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet. /// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
rechtsbehelfsbelehrungVorhanden Boolean @default(true) rechtsbehelfsbelehrungVorhanden Boolean @default(true)
rechtsbehelfsbelehrungBemerkung String? rechtsbehelfsbelehrungBemerkung String?
``` ```
**Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste **Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste
Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“). Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“).
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen — Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
das trägt, verliert aber die Begründung, und die bräuchte man genau dann, das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
wenn man sich im Widerspruchsverfahren darauf beruft. wenn man sich im Widerspruchsverfahren darauf beruft.
**Behelf**: `belehrungZuMonaten()` in **Behelf**: `belehrungZuMonaten()` in
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf `src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
Weiteres in `notizenVerschluesselt`. Weiteres in `notizenVerschluesselt`.
--- ---
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel ## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
```prisma ```prisma
widerspruchFristId String? @unique widerspruchFristId String? @unique
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull) widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
``` ```
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über **Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden. `Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter
Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids
mitziehen. Dasselbe gilt für mitziehen. Dasselbe gilt für
`"sozialhilfe-nachforderung-<nachforderungId>"`. `"sozialhilfe-nachforderung-<nachforderungId>"`.
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`. **Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
--- ---
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel ## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
```prisma ```prisma
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid. /// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
gezahltCent BigInt? gezahltCent BigInt?
gezahltAm DateTime? @db.Date gezahltAm DateTime? @db.Date
rechnungsnummer String? rechnungsnummer String?
``` ```
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was **Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
Träger nachzufragen. Träger nachzufragen.
**Behelf**: `bemerkung`. **Behelf**: `bemerkung`.
--- ---
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein ## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
```prisma ```prisma
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung. /// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
abwesenheitsminderungCent BigInt @default(0) abwesenheitsminderungCent BigInt @default(0)
``` ```
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt **Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach `abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die 25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
nicht festhalten. nicht festhalten.
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und **Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet. neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
--- ---
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein ## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
```prisma ```prisma
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr. /// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
antragsschlussMonat Int? antragsschlussMonat Int?
antragsschlussTag Int? antragsschlussTag Int?
``` ```
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden **Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten, Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
statt sie von Hand anzulegen. statt sie von Hand anzulegen.
**Behelf**: `antragsschluss` als Text im Katalog **Behelf**: `antragsschluss` als Text im Katalog
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der (`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`). `landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
--- ---
## 9. `Nachforderung.notizenVerschluesselt` — klein ## 9. `Nachforderung.notizenVerschluesselt` — klein
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im **Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die („Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas. Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie **Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
verschlüsselt werden sollen, gehört `Nachforderung` in verschlüsselt werden sollen, gehört `Nachforderung` in
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`. `VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Die eigene Anschrift im Schreiben — gegen die echte Anwendung.
*
* Der Weg, um den es geht: einmal unter „Mein Konto" hinterlegen, und
* ab da steht sie im Absenderblock und der Ort in der Datumszeile —
* ohne dass man beim Schreiben daran denken muss.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/** Zurück auf „nichts hinterlegt", damit der Fall auch prüfbar bleibt. */
async function anschriftLeeren(): Promise<void> {
await mitDb(async (db) => {
await db.user.updateMany({ data: { strasse: null, plz: null, ort: null } })
})
}
test.describe('Absenderanschrift', () => {
test.beforeAll(async () => {
await anschriftLeeren()
})
test.afterAll(async () => {
await anschriftLeeren()
})
test('ohne hinterlegte Anschrift steht nur der Name im Brief', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
// Erste Zeile ist der Absender, danach kommt gleich die Leerzeile.
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('')
// Kein Ort in der Datumszeile, also auch kein Komma davor.
expect(text).not.toContain(', den ')
})
test('speichert die Anschrift unter Mein Konto', async ({ page }) => {
await anmelden(page)
await page.goto('/konto')
await page.locator('input#strasse').fill('Cottenbacher Str. 23')
await page.locator('input#plz').fill('95445')
await page.locator('input#ort').fill('Bayreuth')
await page.getByRole('button', { name: 'Anschrift speichern' }).click()
await expect(page.getByText('Deine Anschrift ist gespeichert.')).toBeVisible()
await page.reload()
await expect(page.locator('input#strasse')).toHaveValue('Cottenbacher Str. 23')
})
test('trägt sie in Absenderblock und Datumszeile des Schreibens', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('Cottenbacher Str. 23')
expect(zeilen[2]).toBe('95445 Bayreuth')
expect(text).toContain('Bayreuth, den ')
})
test('steht auch im Rückkaufswert-Schreiben zum Vertrag', async ({ page }) => {
const vertragId = await mitDb(async (db) => {
const vertrag = await db.vertrag.findFirst({
where: { art: 'LEBENSVERSICHERUNG', geloeschtAm: null },
select: { id: true },
})
return vertrag?.id ?? ''
})
test.skip(vertragId === '', 'Kein Lebensversicherungsvertrag im Bestand.')
await anmelden(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Cottenbacher Str. 23')
expect(text).toContain('Bayreuth, den ')
})
})
+164
View File
@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Ansprechpartner am Vorgang — gegen die echte Anwendung.
*
* Geprüft wird die Zusage, um die es geht: Ein Schreiben zum Vorgang
* redet die Sachbearbeiterin namentlich an und trägt ihren Namen über
* der Behörde — ohne benannten Ansprechpartner bleibt alles beim Alten.
*/
const KENNUNG = 'Prüfhausen Partner'
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.vorgang.deleteMany({ where: { titel: { contains: KENNUNG } } })
await db.kontakt.deleteMany({ where: { name: { contains: KENNUNG } } })
})
}
test.describe('Ansprechpartner am Vorgang', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: `${KENNUNG} Petra Sachs`,
organisation: `${KENNUNG} Bezirk`,
rolle: 'SACHBEARBEITER_BEZIRK',
anrede: 'FRAU',
telefon: '0921 604-1234',
strasse: 'Cottenbacher Str. 23',
plz: '95445',
ort: 'Bayreuth',
},
})
await db.vorgang.create({
data: {
betreuteId: person.id,
titel: `${KENNUNG} Antrag auf Hilfe zur Pflege`,
kategorie: 'SOZIALHILFE',
status: 'WARTET_AUF_DRITTE',
gegenstelle: `${KENNUNG} Bezirk`,
aktenzeichen: 'HzP 2026/4711',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('bleibt ohne benannten Ansprechpartner bei der allgemeinen Anrede', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await expect(page.getByText('Keiner benannt', { exact: false }).first()).toBeVisible()
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Damen und Herren,')
})
test('lässt einen Kontakt als Ansprechpartner wählen und zeigt ihn an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page
.locator('select[name="ansprechpartnerId"]')
.selectOption({ label: `${KENNUNG} Petra Sachs · ${KENNUNG} Bezirk` })
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
await page.reload()
const inhalt = await page.locator('body').innerText()
// Vor dem Anruf weiß man, wen man fragt — samt Durchwahl.
expect(inhalt).toContain(`${KENNUNG} Petra Sachs`)
expect(inhalt).toContain('0921 604-1234')
})
test('redet die Sachstandsanfrage namentlich an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
expect(text).not.toContain('Sehr geehrte Damen und Herren,')
// Der Name steht über der Behörde.
const zeilen = text.split('\n')
expect(zeilen[0]).toContain('Petra Sachs')
})
test('übernimmt Name und Anschrift in das Schreiben zum Vorgang', async ({ page }) => {
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { titel: { contains: KENNUNG }, ansprechpartnerId: { not: null } },
select: { id: true },
})
return vorgang?.id ?? ''
})
expect(vorgangId).not.toBe('')
await anmelden(page)
await page.goto(`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgangId}`)
await expect(page.locator('textarea#text')).toBeVisible()
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
// Empfänger und Anschrift sind vorbelegt, nichts abzutippen.
await expect(page.locator('input[name="empfaengerName"]').first()).toHaveValue(
`${KENNUNG} Petra Sachs`,
)
expect(await page.locator('textarea#empfaengerAnschrift').first().inputValue()).toContain(
'95445 Bayreuth',
)
})
test('hält den Wechsel des Ansprechpartners mit Namen im Protokoll fest', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
// Zurück auf „keiner benannt" — der Wechsel muss nachvollziehbar sein.
await page.locator('select[name="ansprechpartnerId"]').selectOption('')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
// Im Protokoll steht der Name, nicht die Id: Zwei cuids nebeneinander
// sind protokolliert, aber nicht nachvollziehbar.
await page.goto('/protokoll')
// textContent statt innerText: Die Felderliste steckt in einem
// zugeklappten <details>.
const protokoll = (await page.locator('body').textContent()) ?? ''
expect(protokoll).toContain('ansprechpartner')
expect(protokoll).toContain(`${KENNUNG} Petra Sachs`)
})
})
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
import { anmelden } from './hilfen'
/**
* Das Benutzermenü muss erreichbar sein, ohne danach zu suchen.
*
* Anlass ist Issue #1: Am Schreibtisch war „Mein Konto" praktisch nicht
* zu finden — und damit auch nicht der Passwortwechsel, der zweite Faktor
* und die Passkey-Verwaltung. Die Seitenleiste hatte keine feste Höhe und
* wuchs mit der Seite mit; ihr Fuß landete deshalb am Ende der Seite statt
* am unteren Bildschirmrand. Auf `/heute` waren das 1632 Pixel bei 900
* Pixel Fensterhöhe.
*
* Der Fehler sah nach nichts aus: Kein Element fehlte, nichts war kaputt,
* die Seite sah richtig aus. Man musste nur ans Seitenende scrollen — und
* darauf kommt niemand, der eine Seitenleiste vor sich hat.
*
* Geprüft wird deshalb nicht „ist das Element im DOM", sondern „steht es
* im sichtbaren Bereich".
*/
/** Der Auslöser des Benutzermenüs in der Seitenleiste am Schreibtisch. */
function ausloeserInSeitenleiste(seite: Page) {
return seite.locator('aside button[aria-haspopup="menu"]')
}
/** Steht das Element vollständig im Fenster, ohne zu scrollen? */
async function imSichtbarenBereich(seite: Page, auswahl: string): Promise<boolean> {
return seite.evaluate((sel) => {
const el = document.querySelector(sel)
if (!el) return false
const k = el.getBoundingClientRect()
return k.top >= 0 && k.bottom <= window.innerHeight && k.height > 0
}, auswahl)
}
test.describe('Benutzermenü am Schreibtisch', () => {
test.skip(({ isMobile }) => Boolean(isMobile), 'Am Handy sitzt es in der Kopfzeile.')
test('steht auch auf langen Seiten im Bild, ohne zu scrollen', async ({ page }) => {
await anmelden(page)
// Seiten, die laenger sind als das Fenster - genau dort trat der
// Fehler auf. /fristen ist als Gegenprobe kurz.
for (const ziel of ['/heute', '/vorgaenge', '/dokumente', '/finanzen', '/fristen']) {
await page.goto(ziel)
await page.waitForLoadState('networkidle')
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild auf ${ziel}`).toBe(true)
}
})
test('bleibt an jeder Scrollposition stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
// Bewusst auch die Mitte pruefen. Nur oben und unten zu messen genuegt
// nicht: Eine mitwachsende Leiste ist am Seitenende zufaellig doch
// sichtbar - der Test waere dann gruen und der Fehler trotzdem da.
for (const anteil of [0, 0.5, 1]) {
await page.evaluate((a) => {
const hoehe = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo(0, hoehe * a)
}, anteil)
await page.waitForTimeout(250)
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild bei ${anteil * 100}% Scrollposition`).toBe(true)
}
})
test('führt in zwei Klicks nach „Mein Konto"', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await ausloeserInSeitenleiste(page).click()
await page.getByRole('menuitem', { name: 'Mein Konto' }).click()
await page.waitForURL('**/konto')
// Und dort steht auch, worum es im Issue ging.
await expect(page.getByRole('heading', { name: 'Mein Konto' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passwort ändern' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passkeys' })).toBeVisible()
})
test('bleibt auf einem flachen Fenster erreichbar', async ({ page }) => {
// Ein Laptop mit 1280x620 ist knapp: Kopf, Betreutenauswahl,
// Navigation und Fuss muessen zusammen hineinpassen. Die Navigation
// dazwischen darf scrollen, der Fuss nicht verschwinden.
await anmelden(page)
await page.setViewportSize({ width: 1280, height: 620 })
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')).toBe(true)
})
})
test.describe('Benutzermenü am Handy', () => {
test.skip(({ isMobile }) => !isMobile, 'Am Schreibtisch sitzt es in der Seitenleiste.')
test('steht in der Kopfzeile und bleibt beim Scrollen stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight))
await page.waitForTimeout(300)
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
})
})
+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')
})
})
+138
View File
@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Demo-Fall mit Kontobewegungen — gegen die echte Anwendung.
*
* Was hier geprüft wird, ist kein Verhalten der Anwendung, sondern die
* Brauchbarkeit der Beispieldaten: Wer die Anwendung zum ersten Mal
* ansieht, soll auf den Finanzseiten etwas vorfinden und nicht erst
* eine CSV importieren müssen.
*
* Die Prüfungen sind bewusst grob gehalten — sie sollen anschlagen, wenn
* der Demo-Fall leer läuft, und nicht bei jeder geänderten Zahl.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
test.describe('Demo-Fall', () => {
test('bringt ein Jahr Kontobewegungen mit', async () => {
const zahlen = await mitDb(async (db) => {
const betreute = await db.betreute.findFirst({
where: { vorname: 'Erika', nachname: 'Musterfrau' },
select: { id: true },
})
if (!betreute) return null
const buchungen = await db.buchung.findMany({
where: { betreuteId: betreute.id, geloeschtAm: null },
select: { datum: true },
orderBy: { datum: 'asc' },
})
const ohneNachweis = await db.buchung.count({
where: { betreuteId: betreute.id, belegId: null, belegFehltBegruendung: null },
})
return { buchungen, ohneNachweis }
})
test.skip(zahlen === null, 'Kein Demo-Fall in dieser Datenbank.')
if (!zahlen) return
expect(zahlen.buchungen.length).toBeGreaterThan(50)
// Über mindestens zwölf Monate verteilt, nicht alle an einem Tag.
const erste = zahlen.buchungen[0]!.datum
const letzte = zahlen.buchungen[zahlen.buchungen.length - 1]!.datum
const monate =
(letzte.getUTCFullYear() - erste.getUTCFullYear()) * 12 +
(letzte.getUTCMonth() - erste.getUTCMonth())
expect(monate).toBeGreaterThanOrEqual(11)
// Genau ein Fall ohne Beleg und ohne Begründung — sonst hätte die
// Belegpflicht nichts zu zeigen, oder zu viel.
expect(zahlen.ohneNachweis).toBe(1)
})
test('der Kontostand stimmt mit der Summe der Buchungen überein', async () => {
const ergebnis = await mitDb(async (db) => {
const konto = await db.konto.findFirst({
where: { art: 'GIRO', betreute: { vorname: 'Erika', nachname: 'Musterfrau' } },
select: { id: true },
})
if (!konto) return null
const staende = await db.saldostand.findMany({
where: { kontoId: konto.id },
orderBy: { stichtag: 'asc' },
})
if (staende.length < 2) return null
const [anfang, ende] = [staende[0]!, staende[staende.length - 1]!]
const bewegungen = await db.buchung.findMany({
where: {
kontoId: konto.id,
geloeschtAm: null,
datum: { gt: anfang.stichtag, lte: ende.stichtag },
},
select: { betragCent: true, richtung: true },
})
const summe = bewegungen.reduce(
(wert, b) => (b.richtung === 'EINNAHME' ? wert + b.betragCent : wert - b.betragCent),
0n,
)
return { erwartet: anfang.betragCent + summe, tatsaechlich: ende.betragCent }
})
test.skip(ergebnis === null, 'Kein Demo-Fall mit zwei Kontoständen.')
if (!ergebnis) return
// Ein Demo-Fall, der sich selbst widerspricht, ist schlimmer als
// einer ohne Buchungen: Man rechnet nach und verliert das Vertrauen.
expect(ergebnis.tatsaechlich).toBe(ergebnis.erwartet)
})
test('die Erkennung wiederkehrender Zahlungen findet mehrere Gruppen', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const inhalt = await page.locator('body').innerText()
// Monatliches und Vierteljährliches, beides erkannt.
expect(inhalt).toContain('Seniorenzentrum')
expect(inhalt).toContain('TeleCare')
expect(inhalt).toContain('Beitragsservice')
expect(inhalt).toContain('monatlich')
expect(inhalt).toContain('vierteljährlich')
// Der eigentliche Vorführeffekt: Das Heim steht in drei Schreibweisen
// auf dem Auszug und wird trotzdem als eine Zahlung geführt.
expect(inhalt).toContain('3 Schreibweisen zusammengefasst')
// Die Sterbekasse steht im zweiten Abschnitt: Zu ihr gibt es einen
// Vertrag, die Zahlungen sind ihm aber noch nicht zugeordnet. Damit
// lässt sich der Handgriff aus der Dauerzahlungs-Zuordnung am
// Demo-Fall ausprobieren, statt ihn nur zu beschreiben.
expect(inhalt).toContain('Schon einem Vertrag zugeordnet')
expect(inhalt).toContain('Nordheim Sterbekasse')
expect(inhalt).toContain('als belegt zuordnen')
})
test('das Kassenbuch zeigt die Buchungen und die fehlende Quittung', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Heimentgelt')
// Die eine Buchung ohne Quittung und ohne Begründung.
expect(inhalt).toContain('Modehaus Lindner')
})
})
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Schreiben als E-Mail — gegen die echte Anwendung.
*
* Geprüft wird der Weg, den die Einheitstests nicht sehen: Umschalten auf
* E-Mail, die abgeleitete Fassung ohne Briefkopf, der .eml-Download und
* die Ablage, die das Schreiben als E-Mail kennzeichnet.
*/
const KONTAKT = 'Prüfhausen Versicherung (E-Mail-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.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' } } })
})
}
test.describe('Schreiben als E-Mail', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: KONTAKT,
organisation: KONTAKT,
rolle: 'SONSTIGES',
email: 'service@pruefhausen.example',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('leitet die E-Mail-Fassung ohne Briefkopf ab, mit Bestellungsabsatz', async ({
page,
}) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
// Kontakt wählen - bringt die E-Mail-Adresse mit.
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await expect(page.locator('input#empfaengerEmail')).toHaveValue(
'service@pruefhausen.example',
)
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const mailtext = await page.locator('textarea#emailText').inputValue()
// Beginnt mit der Anrede - kein Absenderblock, keine Datumszeile.
expect(mailtext.startsWith('Sehr geehrte')).toBe(true)
expect(mailtext).not.toMatch(/^\d{1,2}\.\d{1,2}\.\d{4}$/m)
// Der Nachweis der Bestellung bleibt drin.
expect(mailtext).toMatch(/Betreuungsgericht|bestellt/i)
})
test('lädt den .eml-Entwurf herunter', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' }).click(),
])
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.eml$/)
})
test('zeigt ohne Adresse keine Mailprogramm-Knöpfe', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
// Kopieren geht immer, die Programm-Knöpfe nicht.
await expect(page.getByRole('button', { name: 'Text kopieren' }).last()).toBeVisible()
await expect(page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Im Mailprogramm öffnen' })).toHaveCount(0)
await expect(page.getByText(/Trag oben eine E-Mail-Adresse ein/)).toBeVisible()
})
test('legt die E-Mail als solche gekennzeichnet in die Akte', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.locator('input#betreff').fill('Prüfhausen Testanfrage')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
await page.getByRole('button', { name: 'In die Akte legen' }).click()
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
timeout: 30_000,
})
// In der Akte als E-Mail erkennbar, nicht als Brief.
await page.goto('/dokumente?richtung=AUSGANG')
await expect(page.locator('body')).toContainText('E-Mail: Prüfhausen Testanfrage')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Verweise über Aktengrenzen hinweg — gegen die echte Anwendung.
*
* Die Auswahllisten im Browser zeigen nur die eigene Akte. Darauf darf
* sich der Server nicht verlassen: Wer das Formular verändert, schickt
* jede beliebige Id.
*
* Genau das wird hier nachgestellt — nicht als nachgebauter Aufruf,
* sondern indem dem echten Auswahlfeld ein Eintrag mit einer fremden Id
* untergeschoben und das Formular ganz normal abgeschickt wird. Nur so
* beweist der Test etwas: Er liefe rot, wenn die Prüfung fehlte.
*
* Solange nur eine betreute Person angelegt ist, wäre der Fehler
* folgenlos. Er bliebe es nicht — ein fremder Name stünde im Briefkopf.
*/
const KENNUNG = 'Prüfhausen Fremdakte'
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.betreute.deleteMany({ where: { nachname: { contains: 'Fremdakte' } } })
})
}
test.describe('Verweise auf eine fremde Akte', () => {
test.beforeAll(async () => {
await aufraeumen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('weist einen Kontakt aus einer anderen Akte ab', async ({ page }) => {
// Zweite Akte mit einem Kontakt darin — der angemeldete Benutzer hat
// auf sie keinen Zugriff.
const fremderKontakt = await mitDb(async (db) => {
const fremde = await db.betreute.create({
data: {
vorname: 'Otto',
nachname: `${KENNUNG} Fremdakte`,
geburtsdatum: new Date(Date.UTC(1940, 0, 1)),
},
})
const kontakt = await db.kontakt.create({
data: {
betreuteId: fremde.id,
name: `${KENNUNG} Fremder Sachbearbeiter`,
rolle: 'SACHBEARBEITER_BEZIRK',
},
})
return kontakt.id
})
await anmelden(page)
// Irgendeinen Vorgang der eigenen Akte öffnen.
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { geloeschtAm: null, betreute: { vorname: 'Erika' } },
select: { id: true },
})
return vorgang?.id ?? ''
})
test.skip(vorgangId === '', 'Kein Vorgang in der eigenen Akte.')
await page.goto(`/vorgaenge/${vorgangId}`)
const auswahl = page.locator('select[name="ansprechpartnerId"]')
await auswahl.waitFor()
// Dem echten Auswahlfeld die fremde Id unterschieben und ganz normal
// abschicken — so, wie es ein verändertes Formular täte.
await auswahl.evaluate((feld, id) => {
const eintrag = document.createElement('option')
eintrag.value = id
eintrag.textContent = 'untergeschoben'
;(feld as HTMLSelectElement).append(eintrag)
;(feld as HTMLSelectElement).value = id
}, fremderKontakt)
await page.getByRole('button', { name: 'Speichern' }).click()
// Der Server lehnt ab, mit einer Meldung, die den Grund nennt.
// Grosszuegige Frist: Im Entwicklungsmodus wird die Server Action beim
// ersten Aufruf uebersetzt, und das dauert laenger als die Vorgabe.
await expect(page.getByText(/gehört nicht zu dieser betreuten Person/).first()).toBeVisible({
timeout: 45_000,
})
// Und vor allem: Die Verknüpfung ist nicht entstanden.
const verknuepft = await mitDb((db) =>
db.vorgang.count({ where: { ansprechpartnerId: fremderKontakt } }),
)
expect(verknuepft).toBe(0)
})
test('die Auswahlliste zeigt den fremden Kontakt gar nicht erst', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
const inhalt = await page.locator('body').innerText()
expect(inhalt).not.toContain('Fremder Sachbearbeiter')
})
})
+43
View File
@@ -117,3 +117,46 @@ export function beispielPdf(text = 'Bescheid'): Buffer {
return Buffer.from(pdf, 'latin1') return Buffer.from(pdf, 'latin1')
} }
/**
* Doppelt gerenderte Listen — was beim Suchen schiefgeht.
*
* Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert
* denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab
* Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht
* aber im DOM.
*
* Die Folge im Test:
*
* await expect(page.getByText('Bescheid').first()).toBeVisible()
* // -> "element is not visible", obwohl die Seite richtig aussieht
*
* `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte,
* also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der
* Anwendung.
*
* Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16
* Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar.
*
* Drei Wege, die tragen:
*
* 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht:
*
* const inhalt = await page.locator('body').innerText()
* expect(inhalt).toContain('Bescheid')
*
* innerText liefert nur sichtbaren Text - die versteckte Fassung
* faellt von selbst weg. (Fuer Inhalt in einem zugeklappten
* <details> braucht es dagegen textContent.)
*
* 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird:
*
* page.getByRole('link', { name: 'Bescheid' }).locator('visible=true')
*
* 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um
* Anzahlen geht - die steht nur einmal.
*
* Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet
* jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt
* einen Test, der bei jeder Layoutaenderung umfaellt.
*/
+188
View File
@@ -0,0 +1,188 @@
import { expect, test } from '@playwright/test'
import type { BrowserContext, CDPSession, Page } from '@playwright/test'
import { anmelden, fehlversucheLoeschen } from './hilfen'
/**
* Passkeys gegen einen echten Browser.
*
* WebAuthn laesst sich nicht sinnvoll mit Einheitstests pruefen: Was zaehlt,
* passiert zwischen Browser und Authenticator. Chromium bringt dafuer einen
* virtuellen Authenticator mit, der ueber das Debugprotokoll gesteuert wird -
* er signiert echt, nur ohne Fingerabdrucksensor.
*
* Damit laesst sich der Ablauf vollstaendig durchspielen: einrichten,
* abmelden, mit Passkey anmelden, entfernen. Und, wichtiger, der Fall, den
* die Zusage dieser Anwendung traegt - ein Geraet OHNE Nutzerverifikation
* darf nicht hereinkommen.
*/
type VirtuellerAuthenticator = {
cdp: CDPSession
id: string
}
async function authenticatorAnlegen(
kontext: BrowserContext,
seite: Page,
verifiziert = true,
): Promise<VirtuellerAuthenticator> {
const cdp = await kontext.newCDPSession(seite)
await cdp.send('WebAuthn.enable')
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
// "internal" = im Geraet verbaut, wie Face ID oder Windows Hello.
transport: 'internal',
// Auffindbarer Schluessel - Voraussetzung fuer die Anmeldung ohne
// vorherige Eingabe der E-Mail-Adresse.
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: verifiziert,
automaticPresenceSimulation: true,
},
})
return { cdp, id: authenticatorId }
}
async function passkeyEinrichten(seite: Page, name: string): Promise<void> {
await seite.goto('/konto')
await seite.getByLabel('Neues Gerät').fill(name)
await seite.getByRole('button', { name: 'Passkey einrichten' }).click()
await expect(seite.getByText(`„${name}“ ist eingerichtet.`)).toBeVisible()
}
test.describe('Passkey', () => {
test.beforeEach(async () => {
await fehlversucheLoeschen()
})
test('richtet einen Passkey ein und meldet sich anschliessend damit an', async ({
page,
context,
}) => {
const authenticator = await authenticatorAnlegen(context, page)
// --- 1. Auf dem gewoehnlichen Weg herein --------------------------------
await anmelden(page)
// --- 2. Passkey einrichten ---------------------------------------------
await passkeyEinrichten(page, 'Testgerät')
// Er steht in der Liste, mit Datum und dem Hinweis "noch nicht benutzt".
const eintrag = page.locator('li', { hasText: 'Testgerät' })
await expect(eintrag).toBeVisible()
await expect(eintrag).toContainText('noch nicht benutzt')
// --- 3. Abmelden --------------------------------------------------------
await context.clearCookies()
await page.goto('/anmelden')
await expect(page.getByRole('button', { name: 'Mit Passkey anmelden' })).toBeVisible()
// --- 4. Mit Passkey anmelden -------------------------------------------
await Promise.all([
page.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }),
page.getByRole('button', { name: 'Mit Passkey anmelden' }).click(),
])
// Kein zweiter Schritt: Der Passkey IST der zweite Faktor. Wer hier auf
// der Einrichtungsseite fuer TOTP landet, hat einen Fehler im Ablauf.
expect(new URL(page.url()).pathname).not.toContain('/zwei-faktor')
// --- 5. Die Benutzung ist vermerkt -------------------------------------
await page.goto('/konto')
await expect(page.locator('li', { hasText: 'Testgerät' })).toContainText('zuletzt benutzt')
// --- 6. Wieder entfernen, damit der naechste Lauf sauber startet -------
await page.locator('li', { hasText: 'Testgerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Testgerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
test('laesst ein Geraet ohne Nutzerverifikation nicht herein', async ({ page, context }) => {
// Das ist die Zusage, auf der alles steht: Ein Passkey darf den zweiten
// Faktor nur ersetzen, wenn das Geraet den Benutzer wirklich verifiziert
// hat - Fingerabdruck, Gesicht oder PIN. Ein Schluessel, der auf blosses
// Antippen hin signiert, ist ein Faktor, kein zweiter.
//
// Was dieser Test belegt: Die Anforderung erreicht das Geraet, und ein
// Geraet ohne Verifikation kommt nicht herein. Abgewiesen wird dabei
// schon vom Browser - im Protokoll steht deshalb kein fehlgeschlagener
// Versuch. Die Pruefung auf dem Server (requireUserVerification und die
// ausdrueckliche Abfrage von userVerified in anmeldungPruefen) ist der
// Rueckhalt fuer den Fall, dass jemand die Antwort von Hand baut und den
// Browser umgeht. Der laesst sich hier nicht nachstellen.
const authenticator = await authenticatorAnlegen(context, page, true)
await anmelden(page)
await passkeyEinrichten(page, 'Ungesichertes Gerät')
// Jetzt die Verifikation abschalten - so verhaelt sich ein Geraet ohne
// eingerichtete Bildschirmsperre.
await authenticator.cdp.send('WebAuthn.setUserVerified', {
authenticatorId: authenticator.id,
isUserVerified: false,
})
await context.clearCookies()
await page.goto('/anmelden')
await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click()
// Entscheidend ist nicht der Wortlaut, sondern: Es geht nicht herein.
await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 })
await page.waitForTimeout(1000)
expect(new URL(page.url()).pathname).toBe('/anmelden')
// Aufraeumen: wieder verifizieren, anmelden, Passkey entfernen.
await authenticator.cdp.send('WebAuthn.setUserVerified', {
authenticatorId: authenticator.id,
isUserVerified: true,
})
await fehlversucheLoeschen()
await anmelden(page)
await page.goto('/konto')
await page.locator('li', { hasText: 'Ungesichertes Gerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Ungesichertes Gerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
test('bestaetigt eine sensible Aktion mit dem Passkey statt mit Passwort und Code', async ({
page,
context,
}) => {
const authenticator = await authenticatorAnlegen(context, page)
await anmelden(page)
await passkeyEinrichten(page, 'Bestätigungsgerät')
// Der Vollexport verlangt eine frische Anmeldung.
await page.goto('/berichte/export')
await page.waitForURL(/\/(bestaetigen|berichte)/)
if (new URL(page.url()).pathname === '/bestaetigen') {
await Promise.all([
page.waitForURL((url) => url.pathname !== '/bestaetigen', { timeout: 45_000 }),
page.getByRole('button', { name: 'Mit Passkey bestätigen' }).click(),
])
expect(new URL(page.url()).pathname).toContain('/berichte/export')
}
await page.goto('/konto')
await page.locator('li', { hasText: 'Bestätigungsgerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Bestätigungsgerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
})
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Rückkaufswert am Vertrag — gegen die echte Anwendung.
*
* Die Zusage, die hier geprüft wird: Der Wert steht dort, wo man ihn
* braucht, sein Stichtag steht daneben, ein alter Wert ist als alt
* erkennbar — und es ist derselbe Wert, den das Vermögensverzeichnis
* ausweist, nicht ein zweiter danebengelegter.
*/
const KENNUNG = 'Prüfhausen Wert'
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.vermoegensposten.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
})
}
/** Tag vor N Tagen, als reines Datum. */
function vorTagen(tage: number): Date {
const heute = new Date()
const ziel = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate()))
ziel.setUTCDate(ziel.getUTCDate() - tage)
return ziel
}
test.describe('Rückkaufswert am Vertrag', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
// Ein Vertrag mit frischem Wert, einer mit einem zwei Jahre alten,
// einer ohne jeden Wert.
const frisch = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Frisch`,
gesellschaft: `${KENNUNG} Leben AG`,
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
},
})
const alt = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Alt`,
gesellschaft: `${KENNUNG} Sterbekasse`,
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
},
})
await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Ohne`,
gesellschaft: `${KENNUNG} Renten AG`,
art: 'RENTENVERSICHERUNG_PRIVAT',
status: 'AKTIV',
},
})
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: frisch.id,
art: 'VERSICHERUNG',
bezeichnung: `${KENNUNG} Frisch`,
wertstaende: {
create: [
{
stichtag: vorTagen(30),
wertCent: 521055n,
wertquelle: 'Rückkaufswertmitteilung',
},
{ stichtag: vorTagen(400), wertCent: 480000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
// Bewusst als „Sonstiges" verknüpft: Die Zuordnung zum Vertrag
// hängt an der Verknüpfung, nicht an der Art des Postens.
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: alt.id,
art: 'SONSTIGES',
bezeichnung: `${KENNUNG} Alt`,
wertstaende: {
create: [
{ stichtag: vorTagen(800), wertCent: 310000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('zeigt Wert und Stichtag in der Vertragsübersicht', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('5.210,55')
// Ohne Wert bleibt die Zelle nicht leer, sondern sagt es.
expect(inhalt).toContain('noch nicht erfasst')
})
test('kennzeichnet einen über ein Jahr alten Wert', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
// Der 800 Tage alte Wert des „Alt"-Vertrags trägt das Abzeichen …
await expect(page.getByText('über ein Jahr alt').first()).toBeVisible()
// … und er wird trotz Posten-Art „Sonstiges" überhaupt gefunden.
expect(await page.locator('body').innerText()).toContain('3.100,00')
})
test('zeigt am Vertrag den letzten Wert und die Historie', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Frisch` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await expect(page.getByText('Zuletzt mitgeteilt')).toBeVisible()
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Zuletzt mitgeteilt')
expect(inhalt).toContain('5.210,55')
// Der frühere Wert bleibt abrufbar — die Entwicklung ist die Aussage.
expect(inhalt).toContain('4.800,00')
})
test('trägt einen neuen Wert am Vertrag ein — sichtbar auch im Vermögensverzeichnis', async ({
page,
}) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Ohne` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Mitgeteilten Wert eintragen' }).click()
await page.locator('input[name="stichtag"]').last().fill('2026-08-01')
await page.locator('input[name="wert"]').fill('1.234,56')
await page.getByRole('button', { name: 'Wert aufnehmen' }).click()
await expect(page.getByText('Rückkaufswert in die Historie aufgenommen.')).toBeVisible()
expect(await page.locator('body').innerText()).toContain('1.234,56')
// Dieselbe Zahl, eine Quelle: Das Vermögensverzeichnis kennt sie auch.
await page.goto('/finanzen/vermoegen?stichtag=2026-08-31')
expect(await page.locator('body').innerText()).toContain('1.234,56')
})
})
+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')
})
})
+185
View File
@@ -0,0 +1,185 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Serienabfrage der Rückkaufswerte — gegen die echte Anwendung.
*
* Geprüft wird der ganze Lauf: Vorauswahl, ZIP-Download, die
* Nebenwirkungen (Anfragedatum, Wiedervorlage, Ausgangsdokumente) und der
* Dublettenschutz beim zweiten Lauf am selben Tag.
*/
const KENNUNG = 'Prüfhausen Serie'
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: { empfaenger: { contains: KENNUNG } } })
await db.frist.deleteMany({ where: { titel: { contains: KENNUNG } } })
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
})
}
test.describe('Serienabfrage', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
// Zwei anfragbare, eine ohne Rueckkaufswert, eine ohne Gesellschaft.
await db.vertrag.createMany({
data: [
{
betreuteId: person.id,
bezeichnung: `${KENNUNG} Leben`,
gesellschaft: `${KENNUNG} Lebensversicherung`,
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
},
{
betreuteId: person.id,
bezeichnung: `${KENNUNG} Sterbegeld`,
gesellschaft: `${KENNUNG} Sterbekasse`,
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
},
{
betreuteId: person.id,
bezeichnung: `${KENNUNG} Haftpflicht`,
gesellschaft: `${KENNUNG} Allgemeine`,
art: 'HAFTPFLICHT',
status: 'AKTIV',
},
{
betreuteId: person.id,
bezeichnung: `${KENNUNG} Ohne Gesellschaft`,
gesellschaft: null,
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
},
],
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('wählt richtig vor und benennt die Hindernisse', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege/serienabfrage/seite')
// Die beiden anfragbaren sind vorausgewählt …
await expect(
page.locator('li', { hasText: `${KENNUNG} Leben` }).locator('input[type="checkbox"]'),
).toBeChecked()
await expect(
page.locator('li', { hasText: `${KENNUNG} Sterbegeld` }).locator('input[type="checkbox"]'),
).toBeChecked()
// … die ohne Gesellschaft steht mit Begründung da, ohne Haken …
const ohne = page.locator('li', { hasText: `${KENNUNG} Ohne Gesellschaft` })
await expect(ohne).toContainText('nachtragen')
await expect(ohne.locator('input[type="checkbox"]')).toHaveCount(0)
// … und die Haftpflicht taucht gar nicht erst auf.
await expect(page.locator('li', { hasText: `${KENNUNG} Haftpflicht` })).toHaveCount(0)
// Die Zusammenfassung steht auf dem Knopf, BEVOR etwas entsteht -
// und sie stimmt mit der Zahl der gesetzten Haken ueberein. Auf die
// absolute Zahl laesst sich nicht bauen: Der Demofall bringt eigene
// Vertraege mit.
const haken = await page.locator('input[type="checkbox"]:checked').count()
expect(haken).toBeGreaterThanOrEqual(2)
await expect(
page.getByRole('button', { name: new RegExp(`${haken} Schreiben erzeugen`) }),
).toBeVisible()
})
test('führt den Lauf durch: ZIP, Akte, Wiedervorlagen, Anfragedatum', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege/serienabfrage/seite')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(),
])
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_Rueckkaufswert-Anfragen\.zip$/)
// Nebenwirkungen in der Datenbank: Anfragedatum, Dokumente, Fristen.
const stand = await mitDb(async (db) => {
const angefragt = await db.vertrag.count({
where: {
bezeichnung: { in: [`${KENNUNG} Leben`, `${KENNUNG} Sterbegeld`] },
rueckkaufswertAngefragtAm: { not: null },
},
})
const dokumente = await db.dokument.count({
where: { empfaenger: { contains: KENNUNG }, richtung: 'AUSGANG' },
})
const fristen = await db.frist.count({
where: { titel: { contains: KENNUNG }, typ: 'WIEDERVORLAGE' },
})
return { angefragt, dokumente, fristen }
})
// Beide betreute Personen haben die Verträge - angefragt wird aber nur
// bei der aktiven. Deshalb: genau 2 von 4.
expect(stand.angefragt).toBe(2)
expect(stand.dokumente).toBe(2)
expect(stand.fristen).toBe(2)
// Auf der Seite stehen die beiden jetzt als frisch angefragt.
await page.goto('/vertraege/serienabfrage/seite')
await expect(page.getByText(/bereits angefragt/).first()).toBeVisible()
})
test('erzeugt beim zweiten Lauf am selben Tag keine Dubletten', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege/serienabfrage/seite')
// Alles abwaehlen, dann NUR den frisch angefragten Vertrag anwaehlen.
// So prueft der Lauf genau den Dublettenschutz und fasst die uebrigen
// Vertraege nicht an.
const angehakte = page.locator('input[type="checkbox"]:checked')
while ((await angehakte.count()) > 0) {
await angehakte.first().uncheck()
}
const leben = page.locator('li', { hasText: `${KENNUNG} Leben` })
await leben.locator('input[type="checkbox"]').check()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(),
])
expect(download.suggestedFilename()).toContain('.zip')
// Der Dublettenschutz greift: weiterhin genau ein Dokument und eine
// Frist für diesen Vertrag.
const stand = await mitDb(async (db) => {
const dokumente = await db.dokument.count({
where: { empfaenger: `${KENNUNG} Lebensversicherung` },
})
const fristen = await db.frist.count({
where: { titel: { contains: `${KENNUNG} Leben` }, typ: 'WIEDERVORLAGE' },
})
return { dokumente, fristen }
})
expect(stand.dokumente).toBe(1)
expect(stand.fristen).toBe(1)
})
})
+157
View File
@@ -0,0 +1,157 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Eigene Schreibvorlagen — gegen die echte Anwendung.
*
* Der Weg über die Oberfläche: Vorlage anlegen (mit der zweistufigen
* Bestellungsabsatz-Warnung), aus einer mitgelieferten kopieren, beim
* Schreiben auswählen und die Platzhalter-Auflösung sehen.
*/
const VORLAGE = 'Prüfhausen Testvorlage'
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.schreibvorlage.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
await db.schreibvorlage.deleteMany({ where: { name: { contains: '(eigene Fassung)' } } })
})
}
test.describe('Schreibvorlagen', () => {
test.beforeAll(async () => {
await aufraeumen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('warnt ohne Bestellungsabsatz und speichert erst nach Bestätigung', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen/vorlagen')
await page.getByRole('button', { name: 'Neue Vorlage' }).click()
await page.locator('input[name="name"]').fill(VORLAGE)
await page
.locator('textarea[name="text"]')
.fill('Sehr geehrte Damen und Herren,\n\nbitte um Auskunft zu {{betreute_name}}.\n\nMfG')
// Erster Versuch: Warnung, nicht gespeichert.
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText(/fehlt der Bestellungsabsatz/)).toBeVisible()
// Bestätigen und erneut speichern.
await page.getByText('Bewusst ohne Bestellungsabsatz speichern').click()
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText(`„${VORLAGE}“ ist gespeichert.`)).toBeVisible()
})
test('speichert mit Platzhalter ohne Warnung', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen/vorlagen')
await page.getByRole('button', { name: 'Neue Vorlage' }).click()
await page.locator('input[name="name"]').fill(`${VORLAGE} mit Absatz`)
await page
.locator('textarea[name="text"]')
.fill('Sehr geehrte Damen und Herren,\n\n{{bestellungsabsatz}}\n\nbitte um Auskunft.\n\nMfG')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText(`„${VORLAGE} mit Absatz“ ist gespeichert.`)).toBeVisible()
})
test('übernimmt eine mitgelieferte Vorlage als Kopie', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen/vorlagen')
await page
.locator('li', { hasText: 'Auskunftsersuchen' })
.getByRole('button', { name: 'Als Kopie übernehmen' })
.click()
// Der Editor öffnet mit den kopierten Inhalten.
await expect(page.locator('input[name="name"]')).toHaveValue(/eigene Fassung/)
const text = await page.locator('textarea[name="text"]').inputValue()
expect(text).toContain('{{bestellungsabsatz}}')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText(/ist gespeichert/)).toBeVisible()
})
test('löst die Platzhalter beim Erzeugen auf — mit sichtbaren Lücken', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
// Die gespeicherte Vorlage steht zur Wahl.
const wahl = page.locator('select#vorlageWahl')
await expect(wahl).toBeVisible()
await wahl.selectOption({ label: `${VORLAGE} mit Absatz` })
await page.waitForURL(/vorlageId=/)
const text = await page.locator('textarea#text').inputValue()
// Der Bestellungsabsatz ist aufgelöst (kein Platzhalter mehr) …
expect(text).not.toContain('{{bestellungsabsatz}}')
expect(text).toMatch(/Beschluss|bestellt/)
// … und der Rest des Textes steht drin.
expect(text).toContain('bitte um Auskunft.')
})
test('markiert unbekannte Platzhalter im Text und warnt', async ({ page }) => {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.schreibvorlage.create({
data: {
betreuteId: person.id,
name: `${VORLAGE} kaputt`,
text: 'Sehr geehrte Damen und Herren,\n\n{{bestellungsabsatz}}\n\nHallo {{gibt_es_nicht}}.',
},
})
}
})
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#vorlageWahl').selectOption({ label: `${VORLAGE} kaputt` })
await page.waitForURL(/vorlageId=/)
// Markiert im Text, benannt in der Warnung — kein Abbruch.
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('[Unbekannter Platzhalter: {{gibt_es_nicht}}]')
await expect(page.getByText(/unbekannte Platzhalter/)).toBeVisible()
})
test('gelöschte Vorlage liegt im Papierkorb und kommt zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen/vorlagen')
// Zweistufig - Loeschen fragt erst nach.
const eintrag = page.locator('li', { hasText: `${VORLAGE} mit Absatz` })
await eintrag.getByRole('button', { name: 'Löschen' }).click()
await eintrag.getByRole('button', { name: 'In den Papierkorb' }).click()
await expect(page.getByText(`${VORLAGE} mit Absatz`)).toHaveCount(0)
await page.goto('/einstellungen/papierkorb')
const zeile = page.locator('li', { hasText: `${VORLAGE} mit Absatz` })
await expect(zeile).toBeVisible()
await zeile.getByRole('button', { name: 'Wiederherstellen' }).click()
await page.goto('/einstellungen/vorlagen')
await expect(page.getByText(`${VORLAGE} mit Absatz`)).toBeVisible()
})
})
+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Wiederkehrende Zahlungen gegen die echte Anwendung.
*
* Die Einheitstests nageln die Erkennung fest (tests/wiederkehrend.test.ts).
* Hier geht es um das, was sie nicht sehen können: Kommt die Auswertung mit
* Daten aus der Datenbank überein, führt die Zeile wirklich ins
* Vertragsformular, und überlebt ein Abhaken das Neuladen?
*
* Der Test legt sich seine Buchungen selbst an und räumt sie hinterher weg.
* Er hängt damit nicht am Demofall — der hat heute gar keine Buchungen, und
* ein Test, der auf fremde Daten hofft, wird irgendwann grundlos rot.
*/
/** Eigener Name, damit das Aufräumen nichts Fremdes trifft. */
const EMPFAENGER = 'Prüfhausen Lebensversicherung AG'
const EMPFAENGER_ZWEITE_SCHREIBWEISE = 'PRUEFHAUSEN LEBENSVERSICHERUNG AG'
const EINZELNE = 'Prüfhausen Feuersozietät'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/**
* Legt für JEDE betreute Person dieselben Buchungen an.
*
* Welche Person nach dem Anmelden aktiv ist, hängt an einem Cookie. Statt
* das im Test nachzubauen, bekommen alle dieselben Daten — es sind ein paar
* Zeilen, und der Test wird dadurch unabhängig von der Reihenfolge.
*/
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 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,
}
// Sechs monatliche Abbuchungen, zwei davon in anderer Schreibweise.
for (let monat = 1; monat <= 6; monat++) {
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, monat - 1, 1)),
betragCent: 4250n,
gegenstelle: monat > 4 ? EMPFAENGER_ZWEITE_SCHREIBWEISE : EMPFAENGER,
},
})
}
// Eine einzelne größere Zahlung - der Fall "Jahresbeitrag".
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, 2, 15)),
betragCent: 48_000n,
gegenstelle: EINZELNE,
},
})
}
})
}
async function testdatenEntfernen(): Promise<void> {
await mitDb(async (db) => {
await db.buchung.deleteMany({
where: { gegenstelle: { in: [EMPFAENGER, EMPFAENGER_ZWEITE_SCHREIBWEISE, EINZELNE] } },
})
await db.zahlungsgruppeGeklaert.deleteMany({
where: { anzeigename: { contains: 'Prüfhausen' } },
})
})
}
test.describe('Wiederkehrende Zahlungen', () => {
test.beforeAll(async () => {
await testdatenEntfernen()
await testdatenAnlegen()
})
test.afterAll(async () => {
await testdatenEntfernen()
})
test('findet die monatliche Abbuchung und fasst die Schreibweisen zusammen', async ({
page,
}) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await expect(zeile).toBeVisible()
// Erkannter Rhythmus samt Begründung - nicht nur das Ergebnis.
await expect(zeile).toContainText('monatlich')
await expect(zeile).toContainText('6 Zahlungen')
// 42,50 EUR mal zwölf.
await expect(zeile).toContainText('510,00')
// Zwei Schreibweisen desselben Empfängers, in einer Zeile.
await expect(zeile).toContainText('Schreibweisen zusammengefasst')
// Und angezeigt wird die lesbare, nicht die geschriene aus dem Auszug.
await expect(zeile).toContainText(EMPFAENGER)
})
test('listet die einzelne größere Zahlung getrennt', async ({ page }) => {
// Die Lücke der Gruppierung: Ein Jahresbeitrag erscheint nur einmal.
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
// Die Überschrift steht, und die Zahlung dazu in einer eigenen Zeile.
await expect(page.getByText('Einzelne größere Zahlungen')).toBeVisible()
const zeile = page.locator('tr', { hasText: EINZELNE })
await expect(zeile).toContainText('480,00')
// Ohne Vertrag - genau deshalb steht sie hier.
await expect(zeile).toContainText('keiner')
})
test('übernimmt Gesellschaft und Beitrag ins Vertragsformular', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await zeile.getByRole('link', { name: 'Vertrag anlegen' }).click()
await page.waitForURL('**/vertraege/neu**')
await expect(page.getByText('aus den wiederkehrenden Zahlungen')).toBeVisible()
// Was aus dem Auszug ablesbar ist, steht drin.
await expect(page.locator('input[name="gesellschaft"]')).toHaveValue(
/Prüfhausen Lebensversicherung/,
)
await expect(page.locator('input[name="beitrag"]')).toHaveValue('42,50')
// Was NICHT ablesbar ist, bleibt leer - eine geratene Vorbelegung
// würde übersehen statt korrigiert.
await expect(page.locator('input[name="bezeichnung"]')).toHaveValue('')
})
test('hakt eine Gruppe ab und holt sie wieder zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await zeile.getByRole('button', { name: 'Kein Vertrag' }).click()
await zeile.getByPlaceholder('Warum kein Vertrag?').fill('Nur zum Prüfen')
await zeile.getByRole('button', { name: 'Abhaken', exact: true }).click()
// Aus der Kandidatenliste verschwunden, unter „abgehakt" wieder da.
await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(0)
await expect(page.getByText('Nur zum Prüfen')).toBeVisible()
// Und die Entscheidung überlebt das Neuladen.
await page.reload()
await expect(page.getByText('Nur zum Prüfen')).toBeVisible()
await page.getByRole('button', { name: 'Zurückholen' }).first().click()
await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(1)
})
})
+1854 -2264
View File
File diff suppressed because it is too large Load Diff
+27 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.0.3", "version": "1.11.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
@@ -17,6 +17,9 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:migration": "node scripts/migration-erzeugen.mjs",
"speicher:verschluesseln": "tsx --env-file=.env scripts/speicher-verschluesseln.mts",
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:studio": "prisma studio", "db:studio": "prisma studio",
@@ -35,8 +38,8 @@
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.735.0", "@aws-sdk/client-s3": "3.1116.0",
"@aws-sdk/s3-request-presigner": "3.735.0", "@aws-sdk/s3-request-presigner": "3.1116.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@prisma/client": "6.3.1", "@prisma/client": "6.3.1",
"@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dialog": "1.1.4",
@@ -44,15 +47,17 @@
"@radix-ui/react-label": "2.1.1", "@radix-ui/react-label": "2.1.1",
"@radix-ui/react-slot": "1.1.1", "@radix-ui/react-slot": "1.1.1",
"@react-pdf/renderer": "4.1.6", "@react-pdf/renderer": "4.1.6",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.2",
"archiver": "7.0.1", "archiver": "7.0.1",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"docx": "9.1.1", "docx": "9.1.1",
"lucide-react": "0.474.0", "lucide-react": "0.474.0",
"next": "15.1.6", "next": "15.5.23",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.32",
"nodemailer": "6.10.0", "nodemailer": "9.0.5",
"otpauth": "9.3.6", "otpauth": "9.3.6",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@@ -65,22 +70,22 @@
"zod": "3.24.1" "zod": "3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.50.1", "@playwright/test": "1.62.1",
"@types/archiver": "6.0.3", "@types/archiver": "6.0.3",
"@types/node": "22.13.1", "@types/node": "22.13.1",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "7.0.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.0.8", "@types/react": "19.0.8",
"@types/react-dom": "19.0.3", "@types/react-dom": "19.0.3",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"eslint": "9.19.0", "eslint": "9.39.1",
"eslint-config-next": "15.1.6", "eslint-config-next": "15.5.23",
"postcss": "8.5.1", "postcss": "8.5.26",
"prisma": "6.3.1", "prisma": "6.3.1",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"tsx": "4.19.2", "tsx": "4.20.6",
"typescript": "5.7.3", "typescript": "5.7.3",
"vitest": "3.0.5" "vitest": "3.2.6"
}, },
"allowScripts": { "allowScripts": {
"@prisma/client@6.3.1": true, "@prisma/client@6.3.1": true,
@@ -90,5 +95,14 @@
"prisma@6.3.1": true, "prisma@6.3.1": true,
"sharp@0.33.5": true, "sharp@0.33.5": true,
"unrs-resolver@1.12.2": true "unrs-resolver@1.12.2": true
},
"overrides": {
"next-auth": {
"@simplewebauthn/server": "$@simplewebauthn/server",
"@simplewebauthn/browser": "$@simplewebauthn/browser"
},
"nodemailer": "9.0.5",
"postcss": "8.5.26",
"sharp": "0.35.3"
} }
} }
@@ -0,0 +1,68 @@
-- Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
--
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
-- jeder erzeugten Migration entfernt.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_REGISTRIERT';
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_ENTFERNT';
-- CreateTable
CREATE TABLE "passkey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"name" TEXT NOT NULL,
"geraeteArt" TEXT,
"gesichert" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutztAm" TIMESTAMP(3),
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "passkey_anfrage" (
"id" TEXT NOT NULL,
"challenge" TEXT NOT NULL,
"art" "PasskeyAnfrageArt" NOT NULL,
"userId" TEXT,
"ablaufAm" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "passkey_anfrage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId");
-- CreateIndex
CREATE INDEX "passkey_userId_idx" ON "passkey"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "passkey_anfrage_challenge_key" ON "passkey_anfrage"("challenge");
-- CreateIndex
CREATE INDEX "passkey_anfrage_ablaufAm_idx" ON "passkey_anfrage"("ablaufAm");
-- AddForeignKey
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- Abgehakte Zahlungsgruppen: "das ist kein Vertrag".
--
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "zahlungsgruppe_geklaert" (
"id" TEXT NOT NULL,
"betreuteId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"anzeigename" TEXT NOT NULL,
"begruendung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "zahlungsgruppe_geklaert_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "zahlungsgruppe_geklaert_betreuteId_idx" ON "zahlungsgruppe_geklaert"("betreuteId");
-- CreateIndex
CREATE UNIQUE INDEX "zahlungsgruppe_geklaert_betreuteId_schluessel_key" ON "zahlungsgruppe_geklaert"("betreuteId", "schluessel");
-- AddForeignKey
ALTER TABLE "zahlungsgruppe_geklaert" ADD CONSTRAINT "zahlungsgruppe_geklaert_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- 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/volltextsuche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- 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;
@@ -0,0 +1,28 @@
-- Eigene Schreibvorlagen.
--
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable
CREATE TABLE "schreibvorlage" (
"id" TEXT NOT NULL,
"betreuteId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"betreff" TEXT NOT NULL DEFAULT '',
"text" TEXT NOT NULL,
"ohneBestellungsabsatz" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
"geloeschtAm" TIMESTAMP(3),
CONSTRAINT "schreibvorlage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "schreibvorlage_betreuteId_idx" ON "schreibvorlage"("betreuteId");
-- AddForeignKey
ALTER TABLE "schreibvorlage" ADD CONSTRAINT "schreibvorlage_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
--
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR');
-- AlterTable
ALTER TABLE "kontakt" ADD COLUMN "anrede" "KontaktAnrede" NOT NULL DEFAULT 'OHNE';
-- AlterTable
ALTER TABLE "vorgang" ADD COLUMN "ansprechpartnerId" TEXT;
-- AddForeignKey
ALTER TABLE "vorgang" ADD CONSTRAINT "vorgang_ansprechpartnerId_fkey" FOREIGN KEY ("ansprechpartnerId") REFERENCES "kontakt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- betreuer_anschrift
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "ort" TEXT,
ADD COLUMN "plz" TEXT,
ADD COLUMN "strasse" TEXT;
@@ -0,0 +1,31 @@
-- sicherheitsaudit
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "totpLetzterSchritt" INTEGER;
-- CreateTable
CREATE TABLE "ersteinrichtung" (
"id" TEXT NOT NULL,
"abgeschlossenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
CONSTRAINT "ersteinrichtung_pkey" PRIMARY KEY ("id")
);
-- Bestehende Installationen nachziehen.
--
-- Die Tabelle oben ist die Sperre fuer die Ersteinrichtung. Auf einem
-- System, das schon laeuft, ist die Einrichtung laengst erledigt - ohne
-- diese Zeile stuende die Sperre nach dem Update offen, und der Weg zum
-- Anlegen eines Administrators waere wieder frei.
INSERT INTO "ersteinrichtung" ("id", "userId")
SELECT 'einmalig', (SELECT "id" FROM "benutzer" ORDER BY "erstelltAm" ASC LIMIT 1)
WHERE EXISTS (SELECT 1 FROM "benutzer");
+286 -58
View File
@@ -70,6 +70,13 @@ model User {
// TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext. // TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext.
totpSecretVerschluesselt String? totpSecretVerschluesselt String?
totpAktiviertAm DateTime? totpAktiviertAm DateTime?
/// Der zuletzt akzeptierte TOTP-Zeitschritt (Unixzeit / 30).
///
/// Ein TOTP-Code gilt 30 Sekunden, mit Toleranz bis zu 90. In dieser Zeit
/// liesse sich derselbe Code ein zweites Mal einloesen - abgelesen ueber
/// die Schulter oder abgefangen. Deshalb wird der Schritt festgehalten
/// und nur noch ein groesserer akzeptiert.
totpLetzterSchritt Int?
// Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht. // Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht.
wiederherstellungscodes String[] @default([]) wiederherstellungscodes String[] @default([])
@@ -81,6 +88,13 @@ model User {
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung). // (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
sitzungsGeneration Int @default(0) sitzungsGeneration Int @default(0)
/// Die eigene Anschrift - Absenderblock und Ort in der Datumszeile
/// jedes erzeugten Schreibens. Sie gehoert zum Betreuer und nicht zur
/// betreuten Person: Sie gilt fuer alle Akten.
strasse String?
plz String?
ort String?
// Oberflaechen-Einstellungen // Oberflaechen-Einstellungen
theme String @default("system") // "system" | "hell" | "dunkel" theme String @default("system") // "system" | "hell" | "dunkel"
@@ -89,6 +103,7 @@ model User {
geloeschtAm DateTime? geloeschtAm DateTime?
zugaenge BetreuungsZugang[] zugaenge BetreuungsZugang[]
passkeys Passkey[]
auditEintraege AuditLog[] auditEintraege AuditLog[]
angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon") angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon")
berichte Bericht[] berichte Bericht[]
@@ -120,6 +135,25 @@ model BetreuungsZugang {
@@map("betreuungs_zugang") @@map("betreuungs_zugang")
} }
/// Haelt fest, dass die Ersteinrichtung stattgefunden hat.
///
/// Die Tabelle hat genau eine Zeile mit fester Id. Das ist kein Protokoll,
/// sondern eine Sperre: Vorher stand im Code "wenn es noch keinen Benutzer
/// gibt, lege einen an" - zwei Anfragen gleichzeitig kamen beide durch die
/// Pruefung und legten beide einen Administrator an. Ueber den
/// Primaerschluessel entscheidet das jetzt die Datenbank, und zwar fuer
/// genau einen Gewinner.
model Ersteinrichtung {
/// Immer "einmalig". Der feste Wert ist die Sperre.
id String @id
abgeschlossenAm DateTime @default(now())
/// Wer den Zugang angelegt hat - nur zum Nachvollziehen.
userId String?
@@map("ersteinrichtung")
}
/// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das /// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das
/// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt. /// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt.
model LoginVersuch { model LoginVersuch {
@@ -135,6 +169,91 @@ model LoginVersuch {
@@map("login_versuch") @@map("login_versuch")
} }
/// Ein registrierter Passkey (WebAuthn/FIDO2).
///
/// Der private Schluessel verlaesst das Geraet nie - hier liegt nur der
/// oeffentliche Teil. Ein gestohlener Datenbankabzug erlaubt deshalb keine
/// Anmeldung, anders als bei einem Passworthash, den man offline angreifen
/// kann.
///
/// Ein Passkey zaehlt bei uns als ZWEI Faktoren: Besitz des Geraets plus
/// die Nutzerverifikation darauf (Fingerabdruck, Gesicht oder Geraete-PIN).
/// Deshalb wird bei der Anmeldung "userVerified" erzwungen - siehe
/// src/lib/auth/passkey.ts. Ohne diese Pruefung waere es nur ein Faktor.
model Passkey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Kennung des Authenticators, base64url. Eindeutig ueber alle Benutzer -
/// derselbe Schluessel darf nicht an zwei Zugaengen haengen.
credentialId String @unique
/// Oeffentlicher Schluessel im COSE-Format. Kein Geheimnis.
publicKey Bytes
/// Signaturzaehler des Authenticators. Zaehlt er nicht hoch, obwohl er
/// es vorher tat, ist der Schluessel womoeglich geklont worden.
/// BigInt, weil der Zaehler bis 2^32-1 geht.
counter BigInt @default(0)
/// "usb", "nfc", "ble", "internal", "hybrid" - hilft dem Browser beim
/// naechsten Mal, das richtige Geraet vorzuschlagen.
transports String[] @default([])
/// Vom Benutzer vergeben ("iPhone von Florian"). Bei mehreren Geraeten
/// ist das die einzige Moeglichkeit, sie auseinanderzuhalten.
name String
/// "singleDevice" = liegt nur auf diesem Geraet, "multiDevice" = wird vom
/// Schluesselbund synchronisiert. Steht in der Uebersicht, weil es den
/// Unterschied macht, ob ein verlorenes Geraet den Zugang mitnimmt.
geraeteArt String?
/// Ob der Anbieter den Schluessel gesichert hat (iCloud, Google, Bitwarden).
gesichert Boolean @default(false)
erstelltAm DateTime @default(now())
zuletztBenutztAm DateTime?
@@index([userId])
@@map("passkey")
}
enum PasskeyAnfrageArt {
REGISTRIERUNG
ANMELDUNG
}
/// Kurzlebiger Speicher fuer die Challenge einer laufenden Passkey-Aktion.
///
/// Die Challenge muss serverseitig liegen und genau einmal gelten, sonst
/// liesse sich eine mitgeschnittene Antwort erneut einspielen. Verbraucht
/// wird sie durch DELETE - dass genau eine Zeile geloescht wurde, ist der
/// Beweis, dass sie noch nicht benutzt war.
///
/// Bewusst ohne Fremdschluessel auf User: die Zeile lebt fuenf Minuten und
/// ist kein Fachdatum. Der Wartungslauf raeumt Liegengebliebenes weg.
model PasskeyAnfrage {
id String @id @default(cuid())
/// base64url, zufaellig. Eindeutig, damit das Verbrauchen atomar ist.
challenge String @unique
art PasskeyAnfrageArt
/// Bei der Registrierung bekannt. Bei der Anmeldung nicht - dort weiss
/// der Server erst nach der Antwort, wer sich anmeldet.
userId String?
ablaufAm DateTime
erstelltAm DateTime @default(now())
@@index([ablaufAm])
@@map("passkey_anfrage")
}
// ============================================================================= // =============================================================================
// Betreute Person // Betreute Person
// ============================================================================= // =============================================================================
@@ -191,8 +310,8 @@ model Betreute {
/// Seit der Reform 2023 Leitschnur des Betreuerhandelns. /// Seit der Reform 2023 Leitschnur des Betreuerhandelns.
ermittelterWilleVerschluesselt String? ermittelterWilleVerschluesselt String?
patientenverfuegungVorhanden Boolean @default(false) patientenverfuegungVorhanden Boolean @default(false)
vorsorgevollmachtVorhanden Boolean @default(false) vorsorgevollmachtVorhanden Boolean @default(false)
// --- Betreuungsgericht --------------------------------------------------- // --- Betreuungsgericht ---------------------------------------------------
betreuungsgericht String? betreuungsgericht String?
@@ -226,6 +345,8 @@ model Betreute {
dokumente Dokument[] dokumente Dokument[]
konten Konto[] konten Konto[]
buchungen Buchung[] buchungen Buchung[]
zahlungsgruppen ZahlungsgruppeGeklaert[]
schreibvorlagen Schreibvorlage[]
vermoegensposten Vermoegensposten[] vermoegensposten Vermoegensposten[]
immobilien Immobilie[] immobilien Immobilie[]
vertraege Vertrag[] vertraege Vertrag[]
@@ -285,6 +406,14 @@ model Vorgang {
gegenstelle String? gegenstelle String?
aktenzeichen String? aktenzeichen String?
/// Die Person bei der Gegenstelle, die den Vorgang bearbeitet. Ein
/// namentlich adressiertes Schreiben landet bei ihr statt in der
/// Poststelle - beim Bezirk ist das der Unterschied zwischen zwei
/// Wochen und zwei Monaten. SetNull: Ein Vorgang darf nicht daran
/// haengen, dass es den Kontakt noch gibt.
ansprechpartnerId String?
ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull)
verantwortlich String? verantwortlich String?
angelegtVonId String? angelegtVonId String?
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
@@ -315,8 +444,8 @@ model Vorgang {
kontaktprotokolle Kontaktprotokoll[] kontaktprotokolle Kontaktprotokoll[]
aufgaben Aufgabe[] aufgaben Aufgabe[]
verknuepfungen DokumentVerknuepfung[] verknuepfungen DokumentVerknuepfung[]
tags Tag[] @relation("VorgangTags") tags Tag[] @relation("VorgangTags")
kontakte Kontakt[] @relation("VorgangKontakte") kontakte Kontakt[] @relation("VorgangKontakte")
@@index([betreuteId, status]) @@index([betreuteId, status])
@@index([betreuteId, kategorie]) @@index([betreuteId, kategorie])
@@ -742,6 +871,75 @@ enum BuchungKategorie {
UMBUCHUNG UMBUCHUNG
} }
/// Eine Gruppe wiederkehrender Zahlungen, zu der bewusst KEIN Vertrag
/// gefuehrt wird - Miete, Strom, Rundfunkbeitrag, Finanzamt.
///
/// Die Entscheidung haengt am normalisierten Namen der Gegenstelle, nicht
/// an den Buchungen. Nur so ueberlebt sie den naechsten Kontoauszug: Beim
/// Import entstehen neue Buchungszeilen, der Empfaenger bleibt derselbe.
///
/// ACHTUNG: Der Schluessel wird von normalisieren() in
/// src/lib/finanzen/wiederkehrend.ts gebildet. Aendert sich diese Funktion,
/// passen bestehende Schluessel nicht mehr und abgehakte Gruppen tauchen
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
/// zugeschnitten ist.
/// Eine eigene Schreibvorlage.
///
/// Die mitgelieferten Vorlagen (Rueckkaufswert, Kuendigung,
/// Sachstandsanfrage) stehen bewusst NICHT in dieser Tabelle - sie sind
/// Code, getestet und unveraenderlich. "Wiederherstellen" heisst: die
/// mitgelieferte Fassung als neue Kopie hierher holen.
///
/// Der Text traegt Platzhalter im Format {{name}} - aufgeloest in
/// src/lib/schreiben/platzhalter.ts. Er ist bewusst NICHT verschluesselt:
/// Eine Vorlage ist ein Textbaustein ohne Fallbezug; die Fachdaten kommen
/// erst beim Erzeugen hinein.
model Schreibvorlage {
id String @id @default(cuid())
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
name String
betreff String @default("")
text String
/// Beim Speichern bestaetigt, dass der fehlende Bestellungsabsatz
/// Absicht ist - dann wird nicht bei jedem Bearbeiten erneut gewarnt.
ohneBestellungsabsatz Boolean @default(false)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime?
@@index([betreuteId])
@@map("schreibvorlage")
}
model ZahlungsgruppeGeklaert {
id String @id @default(cuid())
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
/// Normalisierter Name der Gegenstelle.
schluessel String
/// Die Schreibweise, wie sie beim Abhaken auf dem Bildschirm stand -
/// damit spaeter noch erkennbar ist, worum es ging.
anzeigename String
/// Warum kein Vertrag? Freiwillig, aber in einem Jahr Gold wert.
begruendung String?
erstelltAm DateTime @default(now())
@@unique([betreuteId, schluessel])
@@index([betreuteId])
@@map("zahlungsgruppe_geklaert")
}
model Buchung { model Buchung {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -754,21 +952,36 @@ model Buchung {
vorgangId String? vorgangId String?
vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull) vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull)
datum DateTime @db.Date datum DateTime @db.Date
wertstellung DateTime? @db.Date wertstellung DateTime? @db.Date
betragCent BigInt betragCent BigInt
richtung BuchungRichtung richtung BuchungRichtung
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE) kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
gegenstelle String? gegenstelle String?
verwendungszweckVerschluesselt String? verwendungszweckVerschluesselt String?
belegId String? belegId String?
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull) beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang. /// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
belegFehltBegruendung String? 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. /// false = Auslage des Betreuers, die erstattet werden muss.
betrifftBetreute Boolean @default(true) betrifftBetreute Boolean @default(true)
/// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende /// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende
/// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht. /// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht.
vermoegenswirksam Boolean @default(false) vermoegenswirksam Boolean @default(false)
@@ -785,6 +998,7 @@ model Buchung {
@@unique([kontoId, importReferenz]) @@unique([kontoId, importReferenz])
@@index([betreuteId, datum]) @@index([betreuteId, datum])
@@index([betreuteId, kategorie]) @@index([betreuteId, kategorie])
@@index([vertragId])
@@map("buchung") @@map("buchung")
} }
@@ -827,7 +1041,7 @@ model Vermoegensposten {
/// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt; /// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt;
/// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art. /// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art.
bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT) bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT)
/// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII, /// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII,
/// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck. /// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck.
bewertungBegruendungVerschluesselt String? bewertungBegruendungVerschluesselt String?
@@ -852,8 +1066,8 @@ model Wertstand {
vermoegenspostenId String vermoegenspostenId String
vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade) vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade)
stichtag DateTime @db.Date stichtag DateTime @db.Date
wertCent BigInt wertCent BigInt
/// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026", /// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026",
/// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung". /// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung".
wertquelle String wertquelle String
@@ -905,7 +1119,7 @@ model Immobilie {
bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt" bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt"
leerstandSeit DateTime? @db.Date leerstandSeit DateTime? @db.Date
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT) statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
statusBegruendungVerschluesselt String? statusBegruendungVerschluesselt String?
notizenVerschluesselt String? notizenVerschluesselt String?
@@ -951,14 +1165,14 @@ model ImmobilienCheckpunkt {
immobilieId String immobilieId String
immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade) immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade)
titel String titel String
beschreibung String? beschreibung String?
erledigt Boolean @default(false) erledigt Boolean @default(false)
erledigtAm DateTime? @db.Date erledigtAm DateTime? @db.Date
faelligAm DateTime? @db.Date faelligAm DateTime? @db.Date
/// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE. /// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE.
wiederholungRrule String? wiederholungRrule String?
reihenfolge Int @default(0) reihenfolge Int @default(0)
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
@@ -1024,8 +1238,8 @@ model Vertrag {
vertragsnummerVerschluesselt String? vertragsnummerVerschluesselt String?
beginnAm DateTime? @db.Date beginnAm DateTime? @db.Date
endeAm DateTime? @db.Date endeAm DateTime? @db.Date
/// Kuendigungsfrist in Monaten zum Ende der Laufzeit. /// Kuendigungsfrist in Monaten zum Ende der Laufzeit.
kuendigungsfristMonate Int? kuendigungsfristMonate Int?
kuendigungZum DateTime? @db.Date kuendigungZum DateTime? @db.Date
@@ -1050,6 +1264,7 @@ model Vertrag {
vermoegensposten Vermoegensposten[] vermoegensposten Vermoegensposten[]
verknuepfungen DokumentVerknuepfung[] verknuepfungen DokumentVerknuepfung[]
zahlungen Buchung[] @relation("Vertragszahlung")
@@index([betreuteId, status]) @@index([betreuteId, status])
@@map("vertrag") @@map("vertrag")
@@ -1059,6 +1274,13 @@ model Vertrag {
// Kontakte und Kontaktprotokoll // Kontakte und Kontaktprotokoll
// ============================================================================= // =============================================================================
/// Anrede fuer die Briefanrede eines Kontakts.
enum KontaktAnrede {
OHNE
FRAU
HERR
}
enum KontaktRolle { enum KontaktRolle {
RECHTSPFLEGER RECHTSPFLEGER
RICHTER RICHTER
@@ -1093,15 +1315,18 @@ model Kontakt {
name String name String
organisation String? organisation String?
rolle KontaktRolle @default(SONSTIGES) rolle KontaktRolle @default(SONSTIGES)
/// Fuer die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet -
/// nie geraten, das faellt dem Empfaenger sofort auf.
anrede KontaktAnrede @default(OHNE)
telefon String? telefon String?
mobil String? mobil String?
fax String? fax String?
email String? email String?
strasse String? strasse String?
plz String? plz String?
ort String? ort String?
/// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt. /// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt.
aktenzeichen String? aktenzeichen String?
@@ -1111,9 +1336,10 @@ model Kontakt {
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime? geloeschtAm DateTime?
protokolle Kontaktprotokoll[] protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte") vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[] betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle]) @@index([betreuteId, rolle])
@@map("kontakt") @@map("kontakt")
@@ -1149,7 +1375,7 @@ model Kontaktprotokoll {
/// true = persoenlicher Kontakt zur betreuten Person selbst. /// true = persoenlicher Kontakt zur betreuten Person selbst.
mitBetreuter Boolean @default(false) mitBetreuter Boolean @default(false)
inhaltVerschluesselt String? inhaltVerschluesselt String?
/// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht. /// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht.
wuenscheVerschluesselt String? wuenscheVerschluesselt String?
folgeaufgabenVerschluesselt String? folgeaufgabenVerschluesselt String?
@@ -1224,10 +1450,10 @@ model Heimvertrag {
plz String? plz String?
ort String? ort String?
vertragAm DateTime? @db.Date vertragAm DateTime? @db.Date
einzugAm DateTime? @db.Date einzugAm DateTime? @db.Date
auszugAm DateTime? @db.Date auszugAm DateTime? @db.Date
kuendigungsfristTage Int? @default(14) kuendigungsfristTage Int? @default(14)
// Aktuelle Saetze - historische Werte stehen in Heimkostenmonat. // Aktuelle Saetze - historische Werte stehen in Heimkostenmonat.
pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil
@@ -1261,7 +1487,7 @@ model Heimkostenmonat {
zusatzleistungenCent BigInt @default(0) zusatzleistungenCent BigInt @default(0)
// Deckungsseite // Deckungsseite
pflegekasseCent BigInt @default(0) // § 43 SGB XI pflegekasseCent BigInt @default(0) // § 43 SGB XI
/// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer. /// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer.
leistungszuschlagProzent Int @default(0) leistungszuschlagProzent Int @default(0)
leistungszuschlagCent BigInt @default(0) leistungszuschlagCent BigInt @default(0)
@@ -1360,14 +1586,14 @@ model Nachforderung {
erlaeuterung String? erlaeuterung String?
status NachforderungStatus @default(OFFEN) status NachforderungStatus @default(OFFEN)
angefordertAm DateTime? @db.Date angefordertAm DateTime? @db.Date
fristBis DateTime? @db.Date fristBis DateTime? @db.Date
uebersandtAm DateTime? @db.Date uebersandtAm DateTime? @db.Date
/// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung /// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung
/// mit ins Unterlagenpaket. /// mit ins Unterlagenpaket.
begruendungNichtVorhanden String? begruendungNichtVorhanden String?
reihenfolge Int @default(0) reihenfolge Int @default(0)
/// Vorbefuellte Standardunterlage aus der Checkliste. /// Vorbefuellte Standardunterlage aus der Checkliste.
ausCheckliste Boolean @default(false) ausCheckliste Boolean @default(false)
@@ -1387,12 +1613,12 @@ model Bescheid {
antragId String antragId String
antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade) antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade)
art BescheidArt art BescheidArt
bescheidVom DateTime @db.Date bescheidVom DateTime @db.Date
/// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid. /// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid.
zugegangenAm DateTime? @db.Date zugegangenAm DateTime? @db.Date
zeitraumVon DateTime? @db.Date zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date zeitraumBis DateTime? @db.Date
betragCent BigInt? betragCent BigInt?
/// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder /// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder
@@ -1482,11 +1708,11 @@ model Bericht {
betreuteId String betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
art BerichtArt art BerichtArt
titel String titel String
zeitraumVon DateTime? @db.Date zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date zeitraumBis DateTime? @db.Date
stichtag DateTime? @db.Date stichtag DateTime? @db.Date
/// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt. /// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt.
entwurfVerschluesselt String? entwurfVerschluesselt String?
@@ -1527,6 +1753,8 @@ enum AuditAktion {
ANMELDUNG_FEHLGESCHLAGEN ANMELDUNG_FEHLGESCHLAGEN
ABMELDUNG ABMELDUNG
ZWEITER_FAKTOR_EINGERICHTET ZWEITER_FAKTOR_EINGERICHTET
PASSKEY_REGISTRIERT
PASSKEY_ENTFERNT
PASSWORT_GEAENDERT PASSWORT_GEAENDERT
ROLLE_GEAENDERT ROLLE_GEAENDERT
FREIGABE_GEAENDERT FREIGABE_GEAENDERT
@@ -1539,8 +1767,8 @@ model AuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
zeitpunkt DateTime @default(now()) zeitpunkt DateTime @default(now())
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
/// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird. /// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird.
userEmail String? userEmail String?
userName String? userName String?
+315 -2
View File
@@ -22,7 +22,7 @@
* *
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts. * Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/ */
import { PrismaClient } from '@prisma/client' import { PrismaClient, type BuchungKategorie, type Prisma } from '@prisma/client'
import { hash } from '@node-rs/argon2' import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto' import { createCipheriv, randomBytes } from 'node:crypto'
@@ -112,6 +112,14 @@ async function ersterZugang() {
}, },
}) })
// Die Ersteinrichtung ist damit erledigt. Ohne diese Zeile stuende die
// Einrichtungsseite nach dem Seed weiter offen.
await db.ersteinrichtung.upsert({
where: { id: 'einmalig' },
update: {},
create: { id: 'einmalig', userId: benutzer.id },
})
console.log(`Zugang ${email} angelegt.`) console.log(`Zugang ${email} angelegt.`)
if (devSecret) { if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.') console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
@@ -263,9 +271,11 @@ async function demofall(userId: string) {
art: 'GIRO', art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'), ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: { saldostaende: {
// Nur der Anfangsstand. Der spaetere wird weiter unten aus den
// Buchungen gerechnet - ein Demofall, der sich selbst
// widerspricht, waere schlimmer als einer ohne Buchungen.
create: [ create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' }, { stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
{ stichtag: datum('2026-07-31'), betragCent: cent(2967.11), quelle: 'Kontoauszug 07/2026' },
], ],
}, },
}, },
@@ -523,6 +533,309 @@ async function demofall(userId: string) {
}) })
} }
// --- Kontobewegungen ----------------------------------------------------
// Zwoelf Monate auf dem Girokonto. Erzeugt aus einem Muster und nicht
// einzeln abgetippt: So bleibt der Fall aenderbar, und die Betraege
// widersprechen sich nicht.
//
// Die Gegenstellen sind absichtlich uneinheitlich geschrieben. Genau
// damit muss die Zusammenfassung wiederkehrender Zahlungen umgehen - auf
// einem Kontoauszug steht dieselbe Stelle auch mal in Grossbuchstaben.
type Buchungsvorlage = {
tag: number
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstellen: string[]
zweck: string
}
const MONATLICH: Buchungsvorlage[] = [
{
tag: 1,
betrag: 1284.17,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: [
'Deutsche Rentenversicherung Bund',
'DRV BUND',
'Deutsche Rentenversich. Bund',
],
zweck: 'Altersrente',
},
{
tag: 1,
betrag: 96.4,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: ['Zusatzversorgungskasse Bayern', 'ZVK Bayern'],
zweck: 'Betriebsrente',
},
{
tag: 3,
betrag: 1262,
richtung: 'EINNAHME',
kategorie: 'PFLEGEKASSE',
gegenstellen: ['Pflegekasse bei der AOK Bayern', 'AOK Bayern - Pflegekasse'],
zweck: 'Leistung vollstationaere Pflege, Pflegegrad 3',
},
{
tag: 5,
betrag: 2489,
richtung: 'AUSGABE',
kategorie: 'HEIMKOSTEN',
gegenstellen: [
'Seniorenzentrum St. Katharina',
'SENIORENZENTRUM ST KATHARINA',
'Seniorenzentrum St. Katharina gGmbH',
],
zweck: 'Heimentgelt',
},
{
tag: 5,
betrag: 135,
richtung: 'AUSGABE',
kategorie: 'BARBETRAG',
// Eigene Gegenstelle, nicht das Heim: Sonst faellt der Barbetrag
// mit dem Heimentgelt in eine Gruppe, und aus zwei sauberen
// monatlichen Zahlungen wird eine "schwankende".
gegenstellen: ['Barauszahlung Verwahrgeldkonto'],
zweck: 'Barbetrag zur persoenlichen Verfuegung',
},
{
tag: 15,
betrag: 18.4,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstellen: ['Nordheim Sterbekasse VVaG', 'NORDHEIM STERBEKASSE', 'Nordheim Sterbekasse'],
zweck: 'Beitrag Sterbegeldversicherung SG-220417',
},
{
tag: 20,
betrag: 24.9,
richtung: 'AUSGABE',
kategorie: 'TELEFON_INTERNET',
gegenstellen: ['TeleCare Kommunikation GmbH', 'TELECARE KOMMUNIKATION'],
zweck: 'Telefon und Fernsehen im Zimmer',
},
{
tag: 28,
betrag: 4.9,
richtung: 'AUSGABE',
kategorie: 'BANKGEBUEHREN',
gegenstellen: ['Sparkasse Bayreuth'],
zweck: 'Kontofuehrung',
},
]
/** Zwoelf Monate bis einschliesslich Juli 2026. */
const MONATE = [
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
]
const buchungen: Prisma.BuchungCreateManyInput[] = []
MONATE.forEach((monat, lauf) => {
MONATLICH.forEach((vorlage, stelle) => {
// Die Sterbegeld-Lastschrift steigt im Mai 2026. Daran wird die
// Pruefung beim Zuordnen einer Dauerzahlung sichtbar: Der Betrag
// weicht vom hinterlegten Beitrag ab.
const betrag =
vorlage.kategorie === 'VERSICHERUNG' && monat >= '2026-05' ? 19.8 : vorlage.betrag
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(`${monat}-${String(vorlage.tag).padStart(2, '0')}`),
betragCent: cent(betrag),
richtung: vorlage.richtung,
kategorie: vorlage.kategorie,
gegenstelle: vorlage.gegenstellen[lauf % vorlage.gegenstellen.length],
verwendungszweckVerschluesselt: verschluesseln(
vorlage.zweck,
'verwendungszweckVerschluesselt',
),
// Wiederkehrende Zahlungen brauchen keinen Einzelbeleg - der
// Bescheid bzw. der Vertrag dahinter ist der Nachweis.
belegFehltBegruendung:
vorlage.richtung === 'EINNAHME'
? 'Laufender Bezug, Nachweis ist der Bescheid.'
: 'Wiederkehrende Zahlung aus laufendem Vertrag.',
// Die Stelle in der Vorlagenliste gehoert in die Referenz: Zwei
// Renten am Monatsersten haetten sonst dieselbe, und der
// Unique-Index gegen Doppelbuchungen schlaegt zu Recht zu.
importReferenz: `demo-${monat}-${stelle}-${vorlage.kategorie}`,
})
})
})
// Vierteljaehrliches und Einmaliges - damit nicht alles im Monatsraster
// liegt und die Belegpflicht etwas zu zeigen hat.
const EINZELN: {
datum: string
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstelle: string
zweck: string
begruendung?: string
}[] = [
{
datum: '2025-08-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 3. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2025-11-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 4. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 1. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-05-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 2. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 1. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-05-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 2. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-01-19',
betrag: 240,
richtung: 'AUSGABE',
kategorie: 'IMMOBILIE_BETRIEBSKOSTEN',
gegenstelle: 'Stadtwerke Bayreuth',
zweck: 'Abschlag Strom und Wasser, Haus Bindlach',
begruendung: 'Jahresabrechnung steht noch aus.',
},
{
datum: '2026-03-11',
betrag: 10,
richtung: 'AUSGABE',
kategorie: 'ZUZAHLUNG',
gegenstelle: 'Apotheke am Markt',
zweck: 'Zuzahlung Rezept',
begruendung: 'Quittung liegt in der Akte.',
},
// Der Fall fuer die Belegpflicht: keine Quittung und keine Begruendung.
// Genau so sieht eine Buchung aus, die noch Arbeit macht.
{
datum: '2026-04-08',
betrag: 89.95,
richtung: 'AUSGABE',
kategorie: 'BEKLEIDUNG',
gegenstelle: 'Modehaus Lindner',
zweck: 'Winterjacke und zwei Nachthemden',
},
{
datum: '2026-06-17',
betrag: 23,
richtung: 'AUSGABE',
kategorie: 'SONSTIGE_AUSGABE',
gegenstelle: 'Friseur im Haus',
zweck: 'Friseur',
begruendung: 'Bar aus dem Barbetrag bezahlt, keine Quittung ausgestellt.',
},
{
datum: '2026-07-02',
betrag: 62.4,
richtung: 'EINNAHME',
kategorie: 'ZINSEN',
gegenstelle: 'Sparkasse Bayreuth',
zweck: 'Zinsgutschrift',
begruendung: 'Der Kontoauszug ist der Nachweis.',
},
]
for (const eintrag of EINZELN) {
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(eintrag.datum),
betragCent: cent(eintrag.betrag),
richtung: eintrag.richtung,
kategorie: eintrag.kategorie,
gegenstelle: eintrag.gegenstelle,
verwendungszweckVerschluesselt: verschluesseln(
eintrag.zweck,
'verwendungszweckVerschluesselt',
),
belegFehltBegruendung: eintrag.begruendung ?? null,
importReferenz: `demo-${eintrag.datum}-${eintrag.kategorie}`,
})
}
await db.buchung.createMany({ data: buchungen })
// Der Kontostand zum 31.07.2026 wird gerechnet, nicht behauptet: Anfangs-
// stand plus alles, was seither ueber das Konto gelaufen ist. Sonst
// widerspraeche der Demofall sich selbst, sobald jemand nachrechnet.
const bewegungCent = buchungen
.filter((eintrag) => {
const tag = eintrag.datum as Date
return tag > datum('2026-02-14') && tag <= datum('2026-07-31')
})
.reduce((summe, eintrag) => {
const betrag = eintrag.betragCent as bigint
return eintrag.richtung === 'EINNAHME' ? summe + betrag : summe - betrag
}, 0n)
await db.saldostand.create({
data: {
kontoId: giro.id,
stichtag: datum('2026-07-31'),
betragCent: cent(4218.36) + bewegungCent,
quelle: 'Kontoauszug 07/2026',
},
})
console.log('Demofall "Erika Musterfrau" angelegt.') console.log('Demofall "Erika Musterfrau" angelegt.')
} }
+36
View File
@@ -25,6 +25,42 @@ BEGIN
RETURN OLD; RETURN OLD;
END IF; END IF;
/*
* Eine einzige Ausnahme beim UPDATE: das Nullen der beiden
* Fremdschluessel.
*
* Wird eine betreute Person oder ein Benutzer endgueltig geloescht
* (Papierkorb nach 30 Tagen), setzt Postgres wegen ON DELETE SET NULL
* die Spalten betreuteId bzw. userId auf NULL. Ohne diese Ausnahme
* scheitert das Loeschen - und der Papierkorb liesse sich nie leeren.
*
* Der Nachweis bleibt dabei vollstaendig: Zeitpunkt, Aktion, Entitaet,
* Bezeichnung und die verschluesselte Aenderung sind unberuehrt, ebenso
* die Momentaufnahmen userEmail und userName. Es verschwindet nur der
* Verweis auf einen Datensatz, den es nicht mehr gibt.
*
* Jede andere Aenderung wird abgewiesen - auch das Zurueckschreiben
* eines Fremdschluessels auf einen anderen Wert.
*/
IF TG_OP = 'UPDATE'
AND NEW.id = OLD.id
AND NEW.zeitpunkt = OLD.zeitpunkt
AND NEW.aktion = OLD.aktion
AND NEW.entitaet = OLD.entitaet
AND NEW."entitaetId" IS NOT DISTINCT FROM OLD."entitaetId"
AND NEW.bezeichnung IS NOT DISTINCT FROM OLD.bezeichnung
AND NEW."userEmail" IS NOT DISTINCT FROM OLD."userEmail"
AND NEW."userName" IS NOT DISTINCT FROM OLD."userName"
AND NEW."aenderungVerschluesselt" IS NOT DISTINCT FROM OLD."aenderungVerschluesselt"
AND NEW."ipHash" IS NOT DISTINCT FROM OLD."ipHash"
AND NEW."userAgent" IS NOT DISTINCT FROM OLD."userAgent"
-- Die beiden Fremdschluessel duerfen sich aendern, aber NUR nach NULL.
AND (NEW."betreuteId" IS NULL OR NEW."betreuteId" IS NOT DISTINCT FROM OLD."betreuteId")
AND (NEW."userId" IS NULL OR NEW."userId" IS NOT DISTINCT FROM OLD."userId")
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION RAISE EXCEPTION
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.', 'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
TG_OP TG_OP
+32
View File
@@ -0,0 +1,32 @@
/**
* Typen fuer das Werkzeugskript.
*
* Das Skript selbst bleibt .mjs - es laeuft ohne Bauschritt, auch auf
* einem Rechner, auf dem nur Node steht. Die Typen daneben sorgen dafuer,
* dass die Tests trotzdem geprueft werden und nicht stillschweigend
* gegen "any" laufen.
*/
export type GeschuetzterIndex = {
name: string
quelle: string
}
export declare const GESCHUETZTE_INDIZES: GeschuetzterIndex[]
export declare function dropZeilenEntfernen(
sql: string,
geschuetzt?: GeschuetzterIndex[],
): { sql: string; entfernt: string[] }
export declare function hatAnweisungen(sql: string): boolean
export declare function kopfBauen(
name: string,
entfernt: string[],
geschuetzt?: GeschuetzterIndex[],
): string
export declare function ordnernameBauen(name: string, jetzt?: Date): string
export declare function indizesPruefen(): void
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* Eine Migration erzeugen, ohne die Volltextsuche kaputtzumachen.
*
* npm run db:migration -- ansprechpartner
* npm run db:migration -- ansprechpartner --anwenden
*
* Das Problem, das dieses Skript loest: "prisma migrate diff" kennt nur
* das, was im Prisma-Schema steht. Die Trigramm-Indizes der Suche stehen
* aber in prisma/sql/volltextsuche.sql und sind Prisma unbekannt - also
* haelt es sie fuer ueberfluessig und schreibt in JEDE erzeugte Migration:
*
* DROP INDEX "dokument_absender_trgm";
* DROP INDEX "vorgang_titel_trgm";
*
* Fuenfmal ist das passiert, fuenfmal mussten die Zeilen von Hand raus.
* Auf dem Server waere es nicht aufgefallen, weil update.sh die Indizes
* hinterher ohnehin neu anlegt - in der Entwicklung dagegen waere die
* Suche still langsam geworden, ohne dass etwas fehlschlaegt.
*
* Was das Skript macht:
* 1. Migration mit "migrate diff" erzeugen.
* 2. DROP-Zeilen zu geschuetzten Indizes entfernen und im Kopf
* vermerken, welche das waren.
* 3. Auf Wunsch (--anwenden) "migrate deploy" laufen lassen und danach
* pruefen, dass die geschuetzten Indizes wirklich noch stehen.
*
* Nicht automatisch angewendet wird die Migration ohne --anwenden: Erst
* liest man, was da erzeugt wurde.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), '..')
/**
* Indizes, die es nur in prisma/sql/ gibt und die Prisma deshalb nicht
* kennt. Wer dort einen hinzufuegt, traegt ihn hier ein - sonst faellt er
* der naechsten Migration zum Opfer.
*/
export const GESCHUETZTE_INDIZES = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
/**
* Entfernt DROP-INDEX-Anweisungen zu geschuetzten Indizes.
*
* Bewusst eng gefasst: Nur eine Anweisung, die genau einen geschuetzten
* Index nennt, faellt weg. Ein DROP INDEX auf einen Prisma-eigenen Index
* bleibt stehen - der soll ja weg. Der Kommentar "-- DropIndex", den
* Prisma darueber setzt, wird mitgenommen, damit keine verwaisten
* Ueberschriften zurueckbleiben.
*
* @param {string} sql
* @param {{name: string}[]} geschuetzt
* @returns {{sql: string, entfernt: string[]}}
*/
export function dropZeilenEntfernen(sql, geschuetzt = GESCHUETZTE_INDIZES) {
const namen = geschuetzt.map((eintrag) => eintrag.name)
const zeilen = sql.split('\n')
const behalten = []
const entfernt = []
for (let i = 0; i < zeilen.length; i++) {
const zeile = zeilen[i]
const treffer = namen.find(
(name) => /^\s*DROP\s+INDEX\b/i.test(zeile) && zeile.includes(name),
)
if (!treffer) {
behalten.push(zeile)
continue
}
entfernt.push(treffer)
// Die "-- DropIndex"-Ueberschrift darueber mitnehmen, samt der
// Leerzeile davor - sonst bleiben Luecken im Text stehen.
while (behalten.length > 0 && /^\s*--\s*DropIndex\s*$/i.test(behalten[behalten.length - 1])) {
behalten.pop()
while (behalten.length > 0 && behalten[behalten.length - 1].trim() === '') behalten.pop()
}
// Leerzeilen, die der entfernten Anweisung folgen, ebenfalls.
while (i + 1 < zeilen.length && zeilen[i + 1].trim() === '') i++
}
return { sql: behalten.join('\n').replace(/\n{3,}/g, '\n\n').trimStart(), entfernt }
}
/**
* Steht in dem SQL ueberhaupt noch eine Anweisung?
*
* Wichtig fuer den Normalfall: Wenn Schema und Datenbank uebereinstimmen,
* besteht der ganze Diff aus den beiden bekannten DROP-INDEX-Zeilen. Nach
* dem Entfernen bleibt nichts uebrig - und eine leere Migration anzulegen
* waere schlimmer als gar keine, weil sie als angewendet in der Historie
* steht und niemand mehr weiss, wofuer sie gut war.
*
* @param {string} sql
* @returns {boolean}
*/
export function hatAnweisungen(sql) {
return sql
.split('\n')
.some((zeile) => zeile.trim() !== '' && !zeile.trim().startsWith('--'))
}
/** Der Kopfkommentar der erzeugten Datei. */
export function kopfBauen(name, entfernt, geschuetzt = GESCHUETZTE_INDIZES) {
const zeilen = [`-- ${name}`, '--']
if (entfernt.length === 0) {
zeilen.push('-- Erzeugt mit "npm run db:migration". Nichts zu entfernen.')
} else {
const quellen = [
...new Set(
entfernt.map(
(eintrag) => geschuetzt.find((g) => g.name === eintrag)?.quelle ?? 'prisma/sql/',
),
),
]
zeilen.push(
'-- Erzeugt mit "npm run db:migration". Entfernt wurden die',
'-- DROP-INDEX-Anweisungen zu diesen Indizes:',
...entfernt.map((eintrag) => `-- ${eintrag}`),
'--',
`-- Sie stammen aus ${quellen.join(', ')} und sind Prisma unbekannt;`,
'-- ohne sie waere die Volltextsuche still langsam geworden.',
)
}
zeilen.push('')
return zeilen.join('\n')
}
/** Zeitstempel im Prisma-Format, aus der lokalen Uhr. */
export function ordnernameBauen(name, jetzt = new Date()) {
const zwei = (zahl) => String(zahl).padStart(2, '0')
const stempel = [
jetzt.getFullYear(),
zwei(jetzt.getMonth() + 1),
zwei(jetzt.getDate()),
zwei(jetzt.getHours()),
zwei(jetzt.getMinutes()),
zwei(jetzt.getSeconds()),
].join('')
return `${stempel}_${name}`
}
// --- Ab hier: Ausfuehrung ----------------------------------------------------
/**
* Prisma direkt ueber sein Eintrittsskript aufrufen, nicht ueber npx.
*
* Mit "shell: true" wuerde Node warnen (Argumente werden dann nur
* aneinandergehaengt statt maskiert), und ohne Shell findet Windows das
* npx-Skript nicht. Der Weg ueber node loest beides und ist derselbe,
* den scripts/update.sh schon nimmt.
*/
function prisma(argumente, optionen = {}) {
const eintritt = join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js')
return execFileSync(process.execPath, [eintritt, ...argumente], {
cwd: WURZEL,
encoding: 'utf8',
...optionen,
})
}
function abbruch(meldung) {
console.error(`\n Abbruch: ${meldung}\n`)
process.exit(1)
}
function hauptlauf() {
const argumente = process.argv.slice(2)
const anwenden = argumente.includes('--anwenden')
const name = argumente.find((eintrag) => !eintrag.startsWith('--'))
if (!name) {
abbruch(
'Bitte einen Namen angeben: npm run db:migration -- <name> [--anwenden]\n' +
' Beispiel: npm run db:migration -- ansprechpartner',
)
}
if (!/^[a-z0-9_]+$/.test(name)) {
abbruch(`"${name}" taugt nicht als Ordnername. Erlaubt: a-z, 0-9 und _`)
}
console.log(`\n Migration "${name}" erzeugen\n`)
let roh
try {
roh = prisma([
'migrate',
'diff',
'--from-schema-datasource',
'prisma/schema.prisma',
'--to-schema-datamodel',
'prisma/schema.prisma',
'--script',
])
} catch (fehler) {
abbruch(`"prisma migrate diff" ist fehlgeschlagen:\n${fehler.message}`)
}
if (roh.trim() === '' || /^--\s*This is an empty migration/im.test(roh)) {
abbruch('Schema und Datenbank stimmen bereits ueberein - es gibt nichts zu migrieren.')
}
const { sql, entfernt } = dropZeilenEntfernen(roh)
if (!hatAnweisungen(sql)) {
const grund =
entfernt.length > 0
? `Uebrig blieben nur die bekannten DROP-INDEX-Zeilen (${entfernt.join(', ')}).`
: 'Der Vergleich hat keine Anweisungen ergeben.'
abbruch(
`Es gibt nichts zu migrieren. ${grund}\n` +
' Es wurde keine Migration angelegt.',
)
}
const ordner = join(WURZEL, 'prisma', 'migrations', ordnernameBauen(name))
mkdirSync(ordner, { recursive: true })
const datei = join(ordner, 'migration.sql')
writeFileSync(datei, `${kopfBauen(name, entfernt)}\n${sql.trimEnd()}\n`, 'utf8')
console.log(` Geschrieben: ${datei.replace(WURZEL, '.')}`)
if (entfernt.length > 0) {
console.log('\n Entfernte DROP-INDEX-Anweisungen:')
for (const eintrag of entfernt) console.log(` - ${eintrag}`)
} else {
console.log(' Keine DROP-INDEX-Anweisung zu schuetzenden Indizes gefunden.')
}
if (!anwenden) {
console.log('\n Jetzt: die Datei lesen, dann anwenden mit')
console.log(' npm run db:deploy && npm run db:pruefen\n')
return
}
console.log('\n Anwenden ...')
try {
prisma(['migrate', 'deploy'], { stdio: 'inherit' })
} catch {
abbruch('"prisma migrate deploy" ist fehlgeschlagen. Die Datei bleibt liegen.')
}
indizesPruefen()
}
/**
* Stehen die geschuetzten Indizes noch?
*
* Der Sinn des ganzen Skripts haengt an dieser Pruefung: Sie ist der
* Unterschied zwischen "sollte gutgegangen sein" und "nachgesehen".
*/
export function indizesPruefen() {
const bedingung = GESCHUETZTE_INDIZES.map((eintrag) => `'${eintrag.name}'`).join(', ')
const sql = `DO $$
BEGIN
IF (SELECT count(*) FROM pg_indexes WHERE indexname IN (${bedingung})) <> ${GESCHUETZTE_INDIZES.length} THEN
RAISE EXCEPTION 'Es fehlen geschuetzte Indizes';
END IF;
END $$;`
// In den Temp-Ordner, nicht ins Repository: Die Datei ist Werkzeug,
// kein Bestandteil des Projekts.
const datei = join(tmpdir(), `indizes-pruefung-${process.pid}.sql`)
writeFileSync(datei, sql, 'utf8')
try {
prisma(['db', 'execute', '--schema', 'prisma/schema.prisma', '--file', datei], {
stdio: 'pipe',
})
console.log('\n Geschuetzte Indizes stehen:')
for (const eintrag of GESCHUETZTE_INDIZES) console.log(` - ${eintrag.name}`)
console.log('')
} catch {
abbruch(
'Nach der Migration fehlen geschuetzte Indizes.\n' +
' Wiederherstellen mit: npm run db:sql',
)
} finally {
rmSync(datei, { force: true })
}
}
if (process.argv[1] && process.argv[1].endsWith('migration-erzeugen.mjs')) {
hauptlauf()
}
+1
View File
@@ -18,6 +18,7 @@ AUTH_SECRET=${b64(32)}
ENC_KEY_V1=${b64(32)} ENC_KEY_V1=${b64(32)}
ENC_KEY_AKTIV=1 ENC_KEY_AKTIV=1
INDEX_KEY=${b64(32)} INDEX_KEY=${b64(32)}
ERSTEINRICHTUNG_GEHEIMNIS=${b64(24)}
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')} POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen. # WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
Regular → Executable
+13 -3
View File
@@ -75,9 +75,19 @@ done
# --- Selbstpruefung ---------------------------------------------------------- # --- Selbstpruefung ----------------------------------------------------------
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup. # Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist. # Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ # Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \ # genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt
| head -c 5 | grep -q "PGDMP"; then # SIGPIPE - und mit "set -o pipefail" gilt die ganze Kette als
# gescheitert. Bei einer kleinen Datenbank faellt das nicht auf, weil
# alles schon im Puffer steht; mit wachsender Datenbank kippt es, und
# dann meldet die Selbstpruefung eine INTAKTE Sicherung als kaputt.
#
# "dd" liest eine feste Menge und beendet sich regulaer.
KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| dd bs=1 count=5 2>/dev/null || true)"
if [ "$KOPF" = "PGDMP" ]; then
echo "$(date -Iseconds) Selbstpruefung bestanden." echo "$(date -Iseconds) Selbstpruefung bestanden."
else else
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2 echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
+184
View File
@@ -0,0 +1,184 @@
/**
* Bestehende Dateien nachtraeglich in den Umschlag stecken.
*
* npm run speicher:verschluesseln # nur schauen
* npm run speicher:verschluesseln -- --anwenden
*
* Warum es diesen Lauf gibt: Der Objektspeicher-Treiber hat bis v1.10.2
* unverschluesselt abgelegt - es stand nur ServerSideEncryption: 'AES256'
* darauf, also Verschluesselung mit dem Schluessel des Anbieters. Seit
* v1.11.0 liegt derselbe Umschlag darum wie beim Dateitreiber. Neue Dateien
* bekommen ihn beim Ablegen; die alten liegen weiter im Klartext, bis sie
* hier einmal umgeschrieben werden.
*
* Lesen funktioniert waehrenddessen durchgehend: Der Treiber erkennt am
* Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
* Der Lauf laesst sich also jederzeit starten, abbrechen und wiederholen.
*
* Er ist absichtlich langsam und einfach: ein Objekt nach dem anderen,
* lesen, pruefen, zurueckschreiben. Bei ein paar hundert Dokumenten dauert
* das Sekunden, und ein Fehler betrifft immer nur eine Datei.
*
* OHNE --anwenden wird nichts geschrieben.
*/
import { createRequire } from 'node:module'
import { readFile, writeFile } from 'node:fs/promises'
/**
* "server-only" fuer diesen Lauf neutralisieren.
*
* Das Paket wirft beim Import ausserhalb einer Server-Umgebung - genau
* dafuer ist es da. Hier laufen wir aber bewusst ausserhalb von Next, und
* die Alternative waere, den Umschlag im Skript noch einmal zu schreiben.
* Zwei Fassungen derselben Krypto sind die schlechtere Wahl: Wenn eine
* davon abweicht, merkt es niemand, bis eine Datei nicht mehr aufgeht.
*
* Deshalb wird der Modullader vorher umgebogen und danach dynamisch
* importiert - statische Importe wuerden vorgezogen und liefen in den
* Fehler.
*/
const verlangen = createRequire(import.meta.url)
const modulLader = verlangen('node:module') as {
_load: (anfrage: string, ...rest: unknown[]) => unknown
}
const echterLader = modulLader._load
modulLader._load = function (anfrage: string, ...rest: unknown[]) {
if (anfrage === 'server-only') return {}
return echterLader.call(this, anfrage, ...rest)
}
const { env } = await import('../src/lib/env')
const { istVerschluesselt, verschluesselnBinaer } = await import('../src/lib/speicher/umschlag')
const anwenden = process.argv.includes('--anwenden')
type Bilanz = { geprueft: number; umgeschrieben: number; schonGut: number; fehler: number }
function zeile(text = ''): void {
console.log(text)
}
/** Objektspeicher: auflisten, lesen, bei Bedarf zurueckschreiben. */
async function s3Durchgehen(): Promise<Bilanz> {
const s3 = await import('../src/lib/speicher/s3')
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
const schluessel = await s3.objekteAuflisten()
zeile(` ${schluessel.length} Objekte im Eimer "${env.s3.bucket}".`)
zeile()
for (const eintrag of schluessel) {
bilanz.geprueft += 1
try {
const roh = await s3.rohLesen(eintrag)
if (istVerschluesselt(roh)) {
bilanz.schonGut += 1
continue
}
if (anwenden) {
// schreiben() legt den Umschlag selbst an - der Inhalt geht also
// als Klartext hinein und kommt verschluesselt im Eimer an.
await s3.schreiben(eintrag, roh, 'application/octet-stream')
}
bilanz.umgeschrieben += 1
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${eintrag}`)
} catch (fehler) {
bilanz.fehler += 1
zeile(` FEHLER bei ${eintrag}: ${fehler instanceof Error ? fehler.message : fehler}`)
}
}
return bilanz
}
/** Dateitreiber: dasselbe fuer das Ablageverzeichnis. */
async function dateienDurchgehen(): Promise<Bilanz> {
const { readdir, stat } = await import('node:fs/promises')
const { join, relative, sep } = await import('node:path')
const treiber = await import('../src/lib/speicher/datei')
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
const wurzel = treiber.ablageverzeichnis()
zeile(` Verzeichnis ${wurzel}`)
zeile()
async function absteigen(verzeichnis: string): Promise<void> {
let eintraege: string[]
try {
eintraege = await readdir(verzeichnis)
} catch {
return // gibt es noch nicht
}
for (const name of eintraege) {
const pfad = join(verzeichnis, name)
if ((await stat(pfad)).isDirectory()) {
await absteigen(pfad)
continue
}
bilanz.geprueft += 1
try {
const roh = await readFile(pfad)
if (istVerschluesselt(roh)) {
bilanz.schonGut += 1
continue
}
// Der Ablageschluessel ist der Pfad unterhalb der Wurzel, mit
// Schraegstrichen - genau das, was der Treiber beim Schreiben
// benutzt. Er gehoert in die Zusatzdaten, sonst waere die Datei
// hinterher zwar verschluesselt, aber nicht an ihren Platz
// gebunden, und liesse sich unter fremdem Namen wieder einspielen.
const ablageschluessel = relative(wurzel, pfad).split(sep).join('/')
if (anwenden) {
await writeFile(pfad, verschluesselnBinaer(roh, ablageschluessel), { mode: 0o600 })
}
bilanz.umgeschrieben += 1
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${pfad}`)
} catch (fehler) {
bilanz.fehler += 1
zeile(` FEHLER bei ${pfad}: ${fehler instanceof Error ? fehler.message : fehler}`)
}
}
}
await absteigen(wurzel)
return bilanz
}
async function main(): Promise<void> {
zeile()
zeile(' Bestehende Dateien in den Umschlag stecken')
zeile(' ' + '-'.repeat(50))
zeile()
if (!anwenden) {
zeile(' TROCKENLAUF - es wird nichts geschrieben.')
zeile(' Zum Anwenden: npm run speicher:verschluesseln -- --anwenden')
zeile()
}
const treiber = env.speicherTreiber
zeile(` Treiber: ${treiber}`)
zeile()
const bilanz = treiber === 's3' ? await s3Durchgehen() : await dateienDurchgehen()
zeile()
zeile(` Geprueft: ${bilanz.geprueft}`)
zeile(` Schon im Umschlag: ${bilanz.schonGut}`)
zeile(` ${anwenden ? 'Umgeschrieben' : 'Umzuschreiben'}: ${bilanz.umgeschrieben}`)
if (bilanz.fehler > 0) zeile(` Fehler: ${bilanz.fehler}`)
zeile()
if (bilanz.fehler > 0) process.exitCode = 1
}
main().catch((fehler) => {
console.error(fehler)
process.exitCode = 1
})
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env bash
#
# Betreuungs-Cockpit aktualisieren.
#
# ./scripts/update.sh auf die neueste veroeffentlichte Fassung
# ./scripts/update.sh v1.2.0 auf eine bestimmte Fassung
# ./scripts/update.sh --ja ohne Rueckfrage (fuer cron)
# ./scripts/update.sh --pruefen nur nachsehen, nichts aendern
# ./scripts/update.sh --verwerfen eigene Aenderungen an versionierten
# Dateien wegwerfen statt beiseitelegen
#
# Was das Skript tut, in dieser Reihenfolge:
#
# 1. Voraussetzungen pruefen (Verzeichnis, git, docker, .env)
# 2. Fassungen holen und vergleichen
# 3. Zeigen, was sich aendert (Changelog-Abschnitte dazwischen)
# 4. Nachfragen
# 5. SICHERUNG anstossen - und abbrechen, wenn sie nicht klappt
# 6. Eigene Aenderungen beiseitelegen, umschalten, neu bauen, starten
# 7. Die beiden SQL-Skripte ausfuehren (sie sind idempotent)
# 8. Pruefen, ob die neue Fassung wirklich laeuft
#
# Die Reihenfolge ist Absicht: Bis einschliesslich Schritt 4 wird nichts
# veraendert. Wer vorher abbricht - oder wobei etwas schiefgeht -, findet
# alles so vor wie zuvor. Erst danach wird angefasst.
#
# Schritt 5 ist der wichtigste: Eine Datenbank-Migration laesst sich nicht
# zurueckrollen. Ohne Sicherung wird hier nichts aktualisiert.
#
# Schritt 7 ebenfalls: Die SQL-Skripte laufen NICHT mit den Migrationen
# mit. Wer sie vergisst, hat einen veralteten Trigger und merkt es erst,
# wenn etwas nicht geht.
#
# Die .env wird nie angefasst - sie ist nicht versioniert.
# Die geschweiften Klammern sorgen dafuer, dass bash die ganze Datei liest,
# bevor sie irgendetwas ausfuehrt. Ohne sie koennte der git-checkout weiter
# unten das Skript unter den eigenen Fuessen austauschen.
{
set -euo pipefail
# --- Ausgabe -----------------------------------------------------------------
if [ -t 1 ]; then
ROT=$'\033[31m'; GRUEN=$'\033[32m'; GELB=$'\033[33m'; FETT=$'\033[1m'; AUS=$'\033[0m'
else
ROT=''; GRUEN=''; GELB=''; FETT=''; AUS=''
fi
schritt() { printf '\n%s==> %s%s\n' "$FETT" "$1" "$AUS"; }
gut() { printf ' %s%s%s\n' "$GRUEN" "$1" "$AUS"; }
warnung() { printf ' %s%s%s\n' "$GELB" "$1" "$AUS"; }
abbruch() { printf '\n%sAbbruch: %s%s\n\n' "$ROT" "$1" "$AUS" >&2; exit 1; }
# --- Unerwarteter Abbruch ----------------------------------------------------
#
# "set -e" beendet das Skript bei jedem Befehl, der nicht 0 zurueckgibt -
# stumm. Auf dem Bildschirm bricht die Ausgabe dann einfach ab, und man
# sucht an der falschen Stelle.
#
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert: Eine Leitung
# der Form "git log ... | head -15" hat den Erzeuger mit SIGPIPE beendet,
# "pipefail" hat das an die Kette weitergereicht, und das Skript war weg.
#
# Diese Falle sagt jetzt wenigstens, WO es passiert ist. Exitcode 141
# heisst SIGPIPE - dann wurde irgendwo wieder eine Leitung zu frueh
# geschlossen.
unerwartet() {
code=$1
zeile=$2
printf '%sAbbruch in Zeile %s (Exitcode %s).%s\n' "$ROT" "$zeile" "$code" "$AUS" >&2
if [ "$code" = "141" ]; then
printf '%sExitcode 141 ist SIGPIPE: eine Leitung wurde zu frueh geschlossen,%s\n' "$ROT" "$AUS" >&2
printf '%setwa durch "| head". Das ist ein Fehler im Skript, nicht bei dir.%s\n' "$ROT" "$AUS" >&2
fi
printf '%sKam der Abbruch vor Schritt 5, wurde nichts veraendert.%s\n' "$ROT" "$AUS" >&2
}
trap 'unerwartet $? $LINENO' ERR
# --- Aufrufparameter ---------------------------------------------------------
ZIEL=""
OHNE_RUECKFRAGE=0
NUR_PRUEFEN=0
OHNE_SICHERUNG=0
VERWERFEN=0
ENTSCHEIDUNG=""
for arg in "$@"; do
case "$arg" in
--ja|-j) OHNE_RUECKFRAGE=1 ;;
--pruefen|-p) NUR_PRUEFEN=1 ;;
--ohne-sicherung) OHNE_SICHERUNG=1 ;;
--verwerfen) VERWERFEN=1 ;;
--hilfe|-h)
sed -n '2,34p' "$0" | sed 's/^#\s\?//'
exit 0 ;;
-*) abbruch "Unbekannte Angabe: $arg (--hilfe zeigt die Moeglichkeiten)" ;;
*) ZIEL="$arg" ;;
esac
done
# --- 1. Voraussetzungen ------------------------------------------------------
schritt "Voraussetzungen"
cd "$(dirname "$0")/.." || abbruch "Das Projektverzeichnis ist nicht erreichbar."
PROJEKT="$(pwd)"
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml: $PROJEKT"
[ -f .env ] || abbruch "Es gibt keine .env. Ohne sie startet die Anwendung nicht."
command -v git >/dev/null || abbruch "git ist nicht installiert."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) ist nicht verfuegbar."
gut "Verzeichnis: $PROJEKT"
# Nur nachsehen - angefasst wird die Arbeitskopie erst kurz vor dem
# Umschalten, wenn Sicherung und Rueckfrage durch sind. Ein Abbruch
# dazwischen darf keinen Halbzustand hinterlassen.
SCHMUTZIG=""
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
SCHMUTZIG="ja"
warnung "Es gibt eigene Aenderungen an versionierten Dateien."
git status --short --untracked-files=no | sed 's/^/ /'
printf ' Sie werden vor dem Umschalten beiseite gelegt.\n'
else
gut "Arbeitskopie ist sauber"
fi
# --- 2. Was ist da, was kommt ------------------------------------------------
schritt "Fassungen"
if ! git fetch --tags --quiet origin; then
abbruch "Konnte die Fassungen nicht vom Server holen.
Erreichst du ihn? Stimmen die Zugangsdaten?
Es wurde nichts geaendert."
fi
JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
# Kein "| head -1": Siehe den Hinweis zu SIGPIPE weiter unten. Die erste
# Zeile holt die Shell selbst.
ALLE_TAGS="$(git tag --list 'v*' --sort=-v:refname)"
NEUESTE="${ALLE_TAGS%%$'
'*}"
[ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)."
if [ -z "$ZIEL" ]; then
ZIEL="$NEUESTE"
fi
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|| abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden:
$(printf '%s
' "$ALLE_TAGS" | sed -n '1,5p' | sed 's/^/ /')"
printf ' Jetzt: %s\n' "$JETZT"
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
if [ "$(git rev-parse HEAD)" = "$(git rev-parse "$ZIEL^{commit}")" ]; then
gut "Bereits auf dem gewuenschten Stand - nichts zu tun."
exit 0
fi
# --- 3. Was aendert sich -----------------------------------------------------
schritt "Was sich aendert"
# Der Changelog muss aus der ZIELfassung kommen: Der laufende Stand kennt
# die neuen Abschnitte ja noch gar nicht.
CHANGELOG="$(git show "$ZIEL:CHANGELOG.md" 2>/dev/null || true)"
if [ -n "$CHANGELOG" ]; then
# Alle Abschnitte zwischen dem laufenden und dem gewuenschten Stand.
JETZT_NR="${JETZT#v}"
printf '%s
' "$CHANGELOG" | awk -v jetzt="$JETZT_NR" '
/^## \[/ {
nummer = $0
sub(/^## \[/, "", nummer); sub(/\].*/, "", nummer)
if (nummer == jetzt) exit
drucken = (nummer != "Unveröffentlicht")
}
drucken && zeilen < 60 { print " " $0; zeilen++ }
'
else
warnung "Kein CHANGELOG.md in $ZIEL gefunden."
fi
printf '\n'
# "-15" statt "| head -15": Bei mehr Commits als der Grenze schliesst head
# die Leitung, git bekommt SIGPIPE, und mit "set -o pipefail" gilt die ganze
# Kette als gescheitert - "set -e" beendet das Skript dann OHNE Meldung.
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert.
ANZAHL="$(git rev-list --count "HEAD..$ZIEL")"
git log --oneline --no-decorate -15 "HEAD..$ZIEL" | sed 's/^/ /'
if [ "$ANZAHL" -gt 15 ]; then
printf ' ... und %s weitere
' "$((ANZAHL - 15))"
fi
if [ "$NUR_PRUEFEN" = "1" ]; then
printf '\n'
gut "Nur geprueft - es wurde nichts geaendert."
[ -n "$SCHMUTZIG" ] && warnung "Beim echten Lauf wuerden deine Aenderungen beiseite gelegt."
exit 0
fi
# --- 4. Nachfragen -----------------------------------------------------------
if [ "$OHNE_RUECKFRAGE" != "1" ]; then
printf '\n%sJetzt aktualisieren auf %s? [j/N] %s' "$FETT" "$ZIEL" "$AUS"
read -r antwort </dev/tty || antwort=""
case "$antwort" in
[jJ]|[jJ][aA]) ;;
*) printf '\n'; warnung "Abgebrochen, nichts geaendert."; exit 0 ;;
esac
fi
# --- 5. Sicherung ------------------------------------------------------------
if [ "$OHNE_SICHERUNG" = "1" ]; then
schritt "Sicherung wird uebersprungen"
warnung "Auf eigene Gefahr - eine Migration laesst sich nicht zurueckrollen."
else
schritt "Sicherung"
if docker compose ps --status running --services 2>/dev/null | grep -qx sicherung; then
if docker compose exec -T sicherung /bin/sh /sicherung.sh; then
gut "Sicherung angelegt und selbst geprueft."
else
abbruch "Die Sicherung ist fehlgeschlagen. Es wird nichts aktualisiert.
Mit --ohne-sicherung laesst sich das uebergehen - aber ueberleg
dir das zweimal."
fi
else
warnung "Der Dienst 'sicherung' laeuft nicht - es wurde nichts gesichert."
if [ "$OHNE_RUECKFRAGE" != "1" ]; then
printf '\n %sTrotzdem weitermachen? [j/N] %s' "$FETT" "$AUS"
read -r weiter </dev/tty || weiter=""
case "$weiter" in [jJ]|[jJ][aA]) ;; *) abbruch "Abgebrochen." ;; esac
fi
fi
fi
# --- 6. Umschalten und bauen -------------------------------------------------
# Aenderungen an versionierten Dateien blockieren das Umschalten.
#
# Im Betrieb ist das der Normalfall: Man passt die docker-compose.yml an,
# bis etwas laeuft. Genau diese Anpassungen sind spaeter oft schon
# eingearbeitet - das Skript soll sie also nicht zum Hindernis machen.
#
# Verworfen wird trotzdem nichts stillschweigend. Standard ist "beiseite
# legen": git stash macht es rueckholbar. Wer sie wirklich weghaben will,
# nimmt --verwerfen.
#
# Die .env ist nie betroffen - sie ist nicht versioniert.
schritt "Eigene Aenderungen"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
printf '\n Diese versionierten Dateien sind veraendert:\n\n'
git status --short --untracked-files=no | sed 's/^/ /'
printf '\n'
if [ "$VERWERFEN" = "1" ]; then
ENTSCHEIDUNG="verwerfen"
elif [ "$OHNE_RUECKFRAGE" = "1" ]; then
# Ohne jemanden zum Fragen: die sichere Variante.
ENTSCHEIDUNG="beiseite"
else
printf ' %s[b]%s eiseite legen (git stash, spaeter rueckholbar)\n' "$FETT" "$AUS"
printf ' %s[v]%s erwerfen (endgueltig weg)\n' "$FETT" "$AUS"
printf ' %s[a]%s bbrechen\n\n' "$FETT" "$AUS"
printf ' Was soll damit geschehen? [b/v/a] '
read -r wahl </dev/tty || wahl=""
case "$wahl" in
[bB]|'') ENTSCHEIDUNG="beiseite" ;;
[vV]) ENTSCHEIDUNG="verwerfen" ;;
*) abbruch "Abgebrochen, nichts geaendert." ;;
esac
fi
if [ "$ENTSCHEIDUNG" = "verwerfen" ]; then
git checkout -- .
warnung "Aenderungen verworfen."
else
STASH_NAME="vor Update $(date +%Y-%m-%d_%H%M)"
git stash push --quiet --message "$STASH_NAME" -- . \
|| abbruch "Konnte die Aenderungen nicht beiseite legen."
gut "Beiseite gelegt als: $STASH_NAME"
printf ' Ansehen: git stash list\n'
printf ' Zurueck: git stash pop\n'
printf ' Wegwerfen: git stash drop\n'
fi
else
gut "Arbeitskopie ist sauber"
fi
schritt "Umschalten auf $ZIEL"
# Eine unversionierte Datei kann im Weg stehen, wenn die Zielfassung sie
# fuehrt. Das passiert beim Update auf die Fassung, die dieses Skript
# eingefuehrt hat: Man hat es sich von Hand geholt, jetzt gehoert es dazu.
# Solche Dateien werden nicht weggeworfen, sondern beiseite geschoben.
if ! git checkout --quiet "$ZIEL" 2>/dev/null; then
IM_WEG="$(git checkout "$ZIEL" 2>&1 | sed -n '/would be overwritten/,/^Aborting/p' | grep -E '^\s' | tr -d ' ' | grep -v '^$' || true)"
if [ -n "$IM_WEG" ]; then
ZEIT="$(date +%Y%m%d-%H%M%S)"
printf ' Diese unversionierten Dateien stehen im Weg:
'
for datei in $IM_WEG; do
[ -e "$datei" ] || continue
mv "$datei" "${datei}.vor-update-${ZEIT}"
printf ' %s -> %s.vor-update-%s
' "$datei" "$datei" "$ZEIT"
done
git checkout --quiet "$ZIEL" || abbruch "Das Umschalten auf $ZIEL ist fehlgeschlagen.
Deine Aenderungen liegen im Stash: git stash list"
else
abbruch "Das Umschalten auf $ZIEL ist fehlgeschlagen.
$([ -n "$SCHMUTZIG" ] && printf ' Deine Aenderungen liegen im Stash: git stash list')"
fi
fi
gut "$(git describe --tags --always)"
# Das Ausfuehrbarkeitsbit kommt normalerweise aus dem Repository mit. Wer
# Dateien von Hand kopiert hat - etwa um an dieses Skript zu kommen, bevor
# es in der laufenden Fassung existierte -, hat es verloren. Dann waere der
# naechste Aufruf ein "Permission denied", und man sucht an der falschen
# Stelle.
chmod +x scripts/*.sh 2>/dev/null || true
schritt "Bauen und starten"
printf ' Das dauert beim ersten Mal einige Minuten.\n\n'
if ! GIT_COMMIT="$(git rev-parse --short HEAD)" \
BUILD_ZEIT="$(date -Iseconds)" \
docker compose up -d --build; then
printf '\n'
abbruch "Bauen oder Starten ist fehlgeschlagen.
Die Quellen stehen jetzt auf $ZIEL, die Anwendung laeuft aber
noch mit dem alten Abbild - oder gar nicht.
Zurueck auf den vorherigen Stand:
git checkout $JETZT && docker compose up -d --build
$([ -n "$SCHMUTZIG" ] && printf '\n Deine Aenderungen liegen im Stash: git stash list')"
fi
# --- 7. Warten, bis die Anwendung antwortet ----------------------------------
schritt "Warten auf die Anwendung"
# Adresse und Port stehen in der .env - Standard ist die Loopback-Adresse.
ADRESSE="$(grep -E '^APP_BIND=' .env | tail -1 | cut -d= -f2- | tr -d '"' || true)"
PORT="$(grep -E '^APP_PORT=' .env | tail -1 | cut -d= -f2- | tr -d '"' || true)"
[ -n "$ADRESSE" ] || ADRESSE="127.0.0.1"
[ -n "$PORT" ] || PORT="3000"
# Auf 0.0.0.0 kann man nicht zugreifen - dann ueber die Loopback-Adresse.
[ "$ADRESSE" = "0.0.0.0" ] && ADRESSE="127.0.0.1"
GESUND="http://${ADRESSE}:${PORT}/api/gesundheit"
ANTWORT=""
for _ in $(seq 1 60); do
ANTWORT="$(curl -fsS --max-time 5 "$GESUND" 2>/dev/null || true)"
case "$ANTWORT" in *'"bereit"'*) break ;; esac
printf '.'
sleep 3
done
printf '\n'
case "$ANTWORT" in
*'"bereit"'*) gut "Die Anwendung antwortet." ;;
*)
printf '\n'
docker compose logs app --tail 40 | sed 's/^/ /'
abbruch "Die Anwendung antwortet nicht unter $GESUND.
Die letzten Zeilen aus dem Log stehen oben.
Zurueck geht es mit: git checkout $JETZT && docker compose up -d --build"
;;
esac
# --- 8. SQL-Skripte ----------------------------------------------------------
schritt "Volltextsuche und Audit-Schutz"
printf ' Diese beiden laufen nicht mit den Migrationen mit.\n'
printf ' Sie sind idempotent - mehrfaches Ausfuehren schadet nicht.\n\n'
PRISMA="node node_modules/prisma/build/index.js"
for skript in volltextsuche audit-schutz; do
if docker compose exec -T app $PRISMA db execute \
--schema prisma/schema.prisma --file "prisma/sql/${skript}.sql" >/dev/null 2>&1; then
gut "${skript}.sql ausgefuehrt"
else
warnung "${skript}.sql ist fehlgeschlagen - bitte von Hand nachholen:"
printf ' docker compose exec app %s db execute \\\n' "$PRISMA"
printf ' --schema prisma/schema.prisma --file prisma/sql/%s.sql\n' "$skript"
fi
done
# --- 9. Nachschau ------------------------------------------------------------
schritt "Ergebnis"
LAEUFT="$(printf '%s' "$ANTWORT" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
ERWARTET="${ZIEL#v}"
printf ' Erwartet: %s\n' "$ERWARTET"
printf ' Laeuft: %s\n' "${LAEUFT:-unbekannt}"
if [ "$LAEUFT" = "$ERWARTET" ]; then
printf '\n'
gut "Fertig. Version $ERWARTET laeuft."
else
printf '\n'
warnung "Die laufende Fassung stimmt nicht mit der erwarteten ueberein."
warnung "Vermutlich hat der Bau den alten Behaelter stehen lassen:"
printf ' docker compose up -d --build --force-recreate app\n'
fi
printf '\n Zustand: docker compose ps\n'
printf ' Log: docker compose logs app --tail 50\n'
printf ' Zurueck: git checkout %s && docker compose up -d --build\n\n' "$JETZT"
exit 0
}
+6 -1
View File
@@ -97,7 +97,12 @@ schreiben('src/lib/version.ts', ersetzt)
// --- 3. CHANGELOG.md -------------------------------------------------------- // --- 3. CHANGELOG.md --------------------------------------------------------
const heute = new Date().toISOString().slice(0, 10) // Deutsche Zeit, nicht UTC. Sonst bekommt eine Veroeffentlichung nach
// 22 Uhr (Sommerzeit) das Datum des Vortages - dieselbe Zeitzonenfalle,
// derentwegen die Fristen im Schema @db.Date sind.
const heute = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Berlin',
}).format(new Date())
let changelog = lesen('CHANGELOG.md') let changelog = lesen('CHANGELOG.md')
const UNVEROEFFENTLICHT = '## [Unveröffentlicht]' const UNVEROEFFENTLICHT = '## [Unveröffentlicht]'
Regular → Executable
View File
+6 -2
View File
@@ -1,6 +1,6 @@
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung' import { aktiveBetreute, benutzerFuerRoute, reauthGueltig } from '@/lib/auth/sitzung'
import { darf } from '@/lib/auth/rollen' import { darf } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
@@ -33,7 +33,11 @@ function abgelehnt(nachricht: string, status: number): Response {
} }
export async function GET(anfrage: NextRequest): Promise<Response> { export async function GET(anfrage: NextRequest): Promise<Response> {
const benutzer = await erfordereBenutzer('/berichte/export') // Eine Route antwortet mit einem Statuscode, nicht mit einer
// Anmeldeseite als Dateiinhalt.
const angemeldet = await benutzerFuerRoute()
if (!angemeldet.ok) return angemeldet.antwort
const benutzer = angemeldet.benutzer
const gewuenscht = anfrage.nextUrl.searchParams.get('betreute') const gewuenscht = anfrage.nextUrl.searchParams.get('betreute')
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from 'next/link' import Link from 'next/link'
import { import {
Archive, Archive,
CalendarClock, CalendarClock,
FolderArchive, FolderArchive,
Paperclip, Paperclip,
ScrollText, ScrollText,
} from 'lucide-react' } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste' import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext' import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten' import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils' import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' } export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */ /** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [ const BERICHTSFRISTEN = [
'anfangsbericht', 'anfangsbericht',
'jahresbericht', 'jahresbericht',
'vermoegensverzeichnis', 'vermoegensverzeichnis',
'vermoegensuebersicht', 'vermoegensuebersicht',
'rechnungslegung', 'rechnungslegung',
] ]
const WEGE = [ const WEGE = [
{ {
href: '/berichte/jahresbericht', href: '/berichte/jahresbericht',
titel: 'Jahresbericht', titel: 'Jahresbericht',
beschreibung: beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.', 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText, icon: ScrollText,
}, },
{ {
href: '/berichte/belegmappe', href: '/berichte/belegmappe',
titel: 'Belegmappe', titel: 'Belegmappe',
beschreibung: beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.', 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive, icon: FolderArchive,
}, },
{ {
href: '/berichte/unterlagenpaket', href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger', titel: 'Unterlagen für den Träger',
beschreibung: beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.', 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip, icon: Paperclip,
}, },
{ {
href: '/berichte/export', href: '/berichte/export',
titel: 'Vollexport', titel: 'Vollexport',
beschreibung: beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.', 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive, icon: Archive,
}, },
] ]
/** /**
* Die Berichtsuebersicht beantwortet zwei Fragen: * Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?". * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
* *
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten. * Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/ */
export default async function BerichteSeite() { export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([ const [fristen, zeilen] = await Promise.all([
db.frist.findMany({ db.frist.findMany({
where: { where: {
betreuteId, betreuteId,
status: 'OFFEN', status: 'OFFEN',
...nichtGeloescht, ...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({ OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel }, systemSchluessel: { startsWith: schluessel },
})), })),
}, },
orderBy: { datum: 'asc' }, orderBy: { datum: 'asc' },
take: 10, take: 10,
}), }),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben), berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
]) ])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Berichte" titel="Berichte"
pfad={[{ titel: 'Berichte' }]} pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast." beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/> />
<section className="space-y-3" aria-label="Demnächst fällig"> <section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2> <h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? ( {fristen.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '} Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4"> <Link href="/fristen" className="underline underline-offset-4">
Fristen Fristen
</Link> </Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind. , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{fristen.map((frist) => { {fristen.map((frist) => {
const tage = tageBis(frist.datum) const tage = tageBis(frist.datum)
const variante = const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen' tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return ( return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3"> <li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" /> <CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span> <span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)} {formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''} {frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span> </span>
</span> </span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>} {frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}> <Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)} {tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge> </Badge>
</li> </li>
) )
})} })}
</ul> </ul>
)} )}
</section> </section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?"> <section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => { {WEGE.map((weg) => {
const Icon = weg.icon const Icon = weg.icon
return ( return (
<Card key={weg.href}> <Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0"> <CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" /> <Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-lg"> <CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline"> <Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel} {weg.titel}
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription> <CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
) )
})} })}
</section> </section>
<section className="space-y-3" aria-label="Erzeugte Berichte"> <section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2> <h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} /> <BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section> </section>
<HinweisKarten kontext={['BERICHTE']} /> <HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" /> <Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div> </div>
) )
} }
+128 -24
View File
@@ -1,9 +1,16 @@
'use client' 'use client'
import { useActionState } from 'react' import { useActionState, useEffect, useState } from 'react'
import { useFormStatus } from 'react-dom' import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react' import { useRouter } from 'next/navigation'
import { AlertCircle, Fingerprint, Loader2, ShieldCheck } from 'lucide-react'
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen' import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
import { passkeyBestaetigen, passkeyBestaetigungStarten } from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startAuthentication,
} from '@/lib/passkey-browser'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -19,36 +26,133 @@ function Absenden() {
) )
} }
export function BestaetigenFormular({ weiter }: { weiter: string }) { export function BestaetigenFormular({
weiter,
passkeysMoeglich,
}: {
weiter: string
passkeysMoeglich: boolean
}) {
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {}) const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
return ( return (
<form action={aktion} className="space-y-4" noValidate> <div className="space-y-5">
<input type="hidden" name="weiter" value={weiter} /> <form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && ( {zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
{passkeysMoeglich && <PasskeyBestaetigung weiter={weiter} />}
</div>
)
}
/**
* Bestaetigung mit Passkey statt mit Passwort und Code.
*
* Geprueft wird zusaetzlich, dass der Passkey zum angemeldeten Benutzer
* gehoert - sonst koennte jemand mit dem eigenen Finger eine fremde,
* offen stehende Sitzung freischalten. Genau der Fall, gegen den diese
* Seite gebaut ist.
*/
function PasskeyBestaetigung({ weiter }: { weiter: string }) {
const router = useRouter()
const [browserKann, setBrowserKann] = useState(false)
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function bestaetigen() {
setFehler(null)
setLaeuft(true)
try {
const start = await passkeyBestaetigungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startAuthentication({ optionsJSON: start.optionen })
const ergebnis = await passkeyBestaetigen(JSON.stringify(antwort))
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
router.replace(weiter)
router.refresh()
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme))
} finally {
setLaeuft(false)
}
}
if (!browserKann) return null
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
<span className="h-px flex-1 bg-border" />
</div>
{fehler && (
<Alert variant="fehler"> <Alert variant="fehler">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription> <AlertDescription>{fehler}</AlertDescription>
</Alert> </Alert>
)} )}
<Feld id="passwort" beschriftung="Passwort" pflicht> <Button
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus /> type="button"
</Feld> variant="outline"
size="lg"
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht> className="w-full"
<Input onClick={() => void bestaetigen()}
name="code" disabled={laeuft}
inputMode="numeric" >
autoComplete="one-time-code" {laeuft ? (
placeholder="123456" <>
className="text-center font-mono text-lg tracking-[0.4em]" <Loader2 className="animate-spin" aria-hidden="true" />
required Warte auf das Gerät …
/> </>
</Feld> ) : (
<>
<Absenden /> <Fingerprint aria-hidden="true" />
</form> Mit Passkey bestätigen
</>
)}
</Button>
</div>
) )
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { ShieldAlert } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { erfordereBenutzer } from '@/lib/auth/sitzung' import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { passkeysMoeglich } from '@/lib/auth/passkey'
import { BestaetigenFormular } from './formular' import { BestaetigenFormular } from './formular'
export const metadata: Metadata = { title: 'Bestätigen' } export const metadata: Metadata = { title: 'Bestätigen' }
@@ -26,7 +27,7 @@ export default async function BestaetigenSeite({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<BestaetigenFormular weiter={weiter} /> <BestaetigenFormular weiter={weiter} passkeysMoeglich={passkeysMoeglich()} />
</CardContent> </CardContent>
</Card> </Card>
+18 -1
View File
@@ -1,5 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import { KeyRound, Trash2, Users } from 'lucide-react' import { KeyRound, FileText, Trash2, Users } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -69,6 +69,23 @@ export default async function EinstellungenSeite() {
</CardHeader> </CardHeader>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<FileText className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<CardTitle className="text-lg">
<Link href="/einstellungen/vorlagen" className="underline-offset-4 hover:underline">
Schreibvorlagen
</Link>
</CardTitle>
<CardDescription className="mt-1">
Bewährte Formulierungen mit Platzhaltern hinterlegen – beim Erzeugen eines
Schreibens wählbar.
</CardDescription>
</div>
</CardHeader>
</Card>
<Card> <Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0"> <CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Trash2 className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" /> <Trash2 className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
@@ -0,0 +1,51 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf'
import { VorlagenVerwaltung } from '@/components/einstellungen/vorlagen-verwaltung'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { MITGELIEFERTE_VORLAGEN } from '@/lib/schreiben/mitgelieferte-vorlagen'
export const metadata: Metadata = { title: 'Vorlagen' }
export const dynamic = 'force-dynamic'
/**
* Einstellungen → Vorlagen.
*
* Vorlagen hängen an der betreuten Person, nicht am Benutzer: Die
* Formulierungen beziehen sich auf ihren Fall, und beim Vollexport ihrer
* Akte gehören sie mit hinein.
*/
export default async function VorlagenSeite() {
const benutzer = await erfordereBenutzer('/einstellungen/vorlagen')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
if (!darfSchreiben('DOKUMENTE')) redirect('/einstellungen')
const eigene = await db.schreibvorlage.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, betreff: true, text: true },
orderBy: { name: 'asc' },
})
return (
<div className="max-w-5xl space-y-6">
<SeitenKopf
titel="Schreibvorlagen"
beschreibung="Bewährte Formulierungen einmal hinterlegen, beim Schreiben auswählen. Platzhalter füllen sich aus den Daten, die ohnehin im System stehen."
pfad={[
{ titel: 'Einstellungen', href: '/einstellungen' },
{ titel: 'Vorlagen' },
]}
/>
<VorlagenVerwaltung
betreuteId={aktive.id}
eigene={eigene}
mitgelieferte={[...MITGELIEFERTE_VORLAGEN]}
/>
</div>
)
}
@@ -26,7 +26,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Buchung', buchung) const offen = oeffnen('Buchung', buchung)
const [konten, belege] = await Promise.all([ const [konten, belege, vertraege] = await Promise.all([
db.konto.findMany({ db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht }, where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true }, select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -38,6 +38,11 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
orderBy: { erstelltAm: 'desc' }, orderBy: { erstelltAm: 'desc' },
take: 200, take: 200,
}), }),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
]) ])
return ( return (
@@ -64,6 +69,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
betreuteId={betreute.id} betreuteId={betreute.id}
konten={konten} konten={konten}
belege={belege} belege={belege}
vertraege={vertraege}
buchung={{ buchung={{
id: buchung.id, id: buchung.id,
kontoId: buchung.kontoId, kontoId: buchung.kontoId,
@@ -75,6 +81,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
gegenstelle: buchung.gegenstelle, gegenstelle: buchung.gegenstelle,
verwendungszweck: offen.verwendungszweck, verwendungszweck: offen.verwendungszweck,
belegId: buchung.belegId, belegId: buchung.belegId,
vertragId: buchung.vertragId,
belegFehltBegruendung: buchung.belegFehltBegruendung, belegFehltBegruendung: buchung.belegFehltBegruendung,
betrifftBetreute: buchung.betrifftBetreute, betrifftBetreute: buchung.betrifftBetreute,
vermoegenswirksam: buchung.vermoegenswirksam, vermoegenswirksam: buchung.vermoegenswirksam,
@@ -30,7 +30,7 @@ export default async function BuchungNeuSeite({
const schnell = parameter.schnell === '1' const schnell = parameter.schnell === '1'
const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null 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({ db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht }, where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true }, select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -42,6 +42,11 @@ export default async function BuchungNeuSeite({
orderBy: { erstelltAm: 'desc' }, orderBy: { erstelltAm: 'desc' },
take: 200, take: 200,
}), }),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
]) ])
return ( return (
@@ -93,6 +98,7 @@ export default async function BuchungNeuSeite({
: konten : konten
} }
belege={belege} belege={belege}
vertraege={vertraege}
schnellerfassung={schnell} schnellerfassung={schnell}
standardDatum={tagAlsText(heute())} 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 { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine'
import { Buchungsfilter } from '@/components/finanzen/buchungs-filter' import { Buchungsfilter } from '@/components/finanzen/buchungs-filter'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatCent, formatDatum } from '@/lib/utils' import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite' 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 { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { buchungsfilterSchema } from '@/lib/validierung/finanzen' import { buchungsfilterSchema } from '@/lib/validierung/finanzen'
@@ -82,10 +82,7 @@ export default async function BuchungenSeite({
const [zeilen, gesamt, konten] = await Promise.all([ const [zeilen, gesamt, konten] = await Promise.all([
db.buchung.findMany({ db.buchung.findMany({
where: bedingung, where: bedingung,
include: { include: BUCHUNG_EINBEZUG,
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }], orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }],
take: HOECHSTZAHL, take: HOECHSTZAHL,
}), }),
@@ -97,26 +94,7 @@ export default async function BuchungenSeite({
}), }),
]) ])
const buchungen: BuchungEingabe[] = zeilen.map((zeile) => { const buchungen: BuchungEingabe[] = zeilen.map(zuBuchungEingabe)
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 zahlen = kennzahlen(buchungen) const zahlen = kennzahlen(buchungen)
@@ -261,6 +239,11 @@ export default async function BuchungenSeite({
belegId={buchung.belegId} belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null} belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung} begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/> />
</TableCell> </TableCell>
<TableCell className="text-right align-top"> <TableCell className="text-right align-top">
+33 -5
View File
@@ -1,5 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { ArrowRight, Banknote, FileDown, Landmark, Plus, Upload } from 'lucide-react' import { ArrowRight, Banknote, FileDown, Landmark, Plus, Repeat, Upload } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -16,10 +17,11 @@ import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils' import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite' import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum' 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 { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr' import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam' import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
export const metadata = { title: 'Vermögen & Kasse' } export const metadata = { title: 'Vermögen & Kasse' }
@@ -48,6 +50,7 @@ export default async function FinanzenSeite() {
const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1) const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1)
const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate) const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate)
const zahlen = kennzahlen(letzteZwoelf) const zahlen = kennzahlen(letzteZwoelf)
const offeneGruppen = await offeneZahlungsgruppen(betreute.id)
const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8) const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8)
@@ -118,9 +121,13 @@ export default async function FinanzenSeite() {
titel="Belegquote (12 Monate)" titel="Belegquote (12 Monate)"
wert={`${zahlen.beleg.quoteProzent} %`} wert={`${zahlen.beleg.quoteProzent} %`}
hinweis={ hinweis={
zahlen.beleg.ohneNachweis > 0 zahlen.beleg.zuPruefen > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung` ? `${zahlen.beleg.zuPruefen} Zahlung(en) weichen vom hinterlegten Beitrag ab`
: 'Alle Buchungen sind belegt oder begründet' : 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'} ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
href="/finanzen/buchungen?nurOhneBeleg=on" href="/finanzen/buchungen?nurOhneBeleg=on"
@@ -134,6 +141,22 @@ export default async function FinanzenSeite() {
/> />
</div> </div>
{offeneGruppen > 0 && (
<Alert variant="hinweis">
<Repeat aria-hidden="true" />
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
<span>
<strong>{offeneGruppen}</strong> regelmäßige Abbuchung
{offeneGruppen === 1 ? '' : 'en'} ohne hinterlegten Vertrag. Wer regelmäßig
abbucht, hat einen — und der fehlt dann im Vermögensverzeichnis.
</span>
<Button asChild size="sm" variant="outline">
<Link href="/finanzen/wiederkehrend">Ansehen</Link>
</Button>
</AlertDescription>
</Alert>
)}
{zahlen.vermischungen > 0 && <Vermischungswarnung />} {zahlen.vermischungen > 0 && <Vermischungswarnung />}
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" /> <Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" />
@@ -299,6 +322,11 @@ export default async function FinanzenSeite() {
belegId={buchung.belegId} belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null} belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung} begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/> />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
+2 -1
View File
@@ -18,6 +18,7 @@ import {
VERMOEGENS_ART_EINZAHL, VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien' } from '@/lib/finanzen/kategorien'
import { vermoegenLaden } from '@/lib/berichte/gemeinsam' import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { wertVeraltet } from '@/lib/finanzen/wertalter'
import type { SozialhilfeBewertung } from '@prisma/client' import type { SozialhilfeBewertung } from '@prisma/client'
export const metadata = { title: 'Vermögen' } export const metadata = { title: 'Vermögen' }
@@ -235,7 +236,7 @@ function Postentabelle({
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground"> <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatDatum(eintrag.bewertetAm)} {formatDatum(eintrag.bewertetAm)}
{eintrag.alterTage > 365 && ( {wertVeraltet(eintrag.alterTage) && (
<Badge variant="gelb" className="ml-2"> <Badge variant="gelb" className="ml-2">
über ein Jahr alt über ein Jahr alt
</Badge> </Badge>
@@ -0,0 +1,337 @@
import Link from 'next/link'
import { AlertCircle, FileSearch, Plus, Repeat } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { 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'
import { wiederkehrendLaden } from '@/lib/finanzen/wiederkehrend-laden'
import {
EINZELZAHLUNG_AB_CENT,
RHYTHMUS_BESCHRIFTUNG,
rhythmusErklaerung,
} from '@/lib/finanzen/wiederkehrend'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
export const metadata = { title: 'Wiederkehrende Zahlungen' }
export const dynamic = 'force-dynamic'
/**
* Wiederkehrende Zahlungen — die Suche nach unbekannten Verträgen.
*
* Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt. Die
* Antwort steht im Kontoauszug: Wer regelmäßig abbucht, hat einen Vertrag.
* Diese Seite macht aus hunderten Buchungszeilen eine Arbeitsliste.
*
* Sie legt von sich aus nichts an. Jede Zeile endet in einer Entscheidung,
* die ein Mensch trifft — Vertrag anlegen oder abhaken.
*/
export default async function WiederkehrendSeite() {
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const bild = await wiederkehrendLaden(betreute.id)
const ohneVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId === null)
const mitVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId !== null)
return (
<div className="space-y-6">
<SeitenKopf
titel="Wiederkehrende Zahlungen"
beschreibung="Wer regelmäßig abbucht, hat einen Vertrag. Hier stehen die Kandidaten, zu denen noch keiner in der Akte liegt."
aktionen={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen">Zu den Buchungen</Link>
</Button>
}
/>
{bild.ausgabenGesamt === 0 ? (
<Leerzustand
icon={FileSearch}
titel="Noch keine Ausgaben erfasst"
beschreibung="Diese Auswertung liest die Kontobewegungen. Importiere zuerst einen Kontoauszug, dann findest du hier die regelmäßigen Abbuchungen."
aktion={
<Button asChild>
<Link href="/finanzen/buchungen/import">Kontoauszug einlesen</Link>
</Button>
}
/>
) : (
<>
{/* --- Kandidaten ohne Vertrag ----------------------------------- */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Repeat className="size-5" aria-hidden="true" />
Ohne hinterlegten Vertrag
{ohneVertrag.length > 0 && (
<Badge variant="secondary">{ohneVertrag.length}</Badge>
)}
</CardTitle>
<CardDescription>
Nach Jahresbetrag sortiert — was am meisten kostet, will man zuerst geklärt haben.
</CardDescription>
</CardHeader>
<CardContent>
{ohneVertrag.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zu jeder regelmäßigen Abbuchung liegt ein Vertrag in der Akte. Das ist der
Zustand, den man haben will.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Rhythmus</TableHead>
<TableHead className="text-right">je Zahlung</TableHead>
<TableHead className="text-right">im Jahr</TableHead>
<TableHead>Zeitraum</TableHead>
{schreiben && <TableHead className="text-right">Und jetzt?</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{ohneVertrag.map((gruppe) => (
<TableRow key={gruppe.schluessel}>
<TableCell className="align-top">
<p className="font-medium">{gruppe.anzeigename}</p>
{gruppe.schreibweisen.length > 1 && (
<p className="mt-0.5 text-xs text-muted-foreground">
{gruppe.schreibweisen.length} Schreibweisen zusammengefasst
</p>
)}
</TableCell>
<TableCell className="align-top">
<span
className={
gruppe.rhythmus === 'UNREGELMAESSIG'
? 'text-muted-foreground'
: undefined
}
>
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]}
</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{rhythmusErklaerung(gruppe)}
</p>
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.betragCent)} />
{gruppe.betragSchwankt && (
<p className="mt-0.5 text-xs text-ampel-orange">
schwankt: {formatCent(centZuZahl(gruppe.kleinsterBetragCent))} bis{' '}
{formatCent(centZuZahl(gruppe.groessterBetragCent))}
</p>
)}
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.jahresbetragCent)} />
{gruppe.rhythmus === 'UNREGELMAESSIG' && (
<p className="mt-0.5 text-xs text-muted-foreground">
tatsächlich gezahlt
</p>
)}
</TableCell>
<TableCell className="align-top text-sm text-muted-foreground">
{formatDatum(gruppe.ersteZahlung)}
<br />
bis {formatDatum(gruppe.letzteZahlung)}
</TableCell>
{schreiben && (
<TableCell className="align-top">
<div className="flex flex-col items-end gap-2">
<Button asChild size="sm" variant="outline">
<Link
href={`/vertraege/neu?gesellschaft=${encodeURIComponent(
gruppe.anzeigename,
)}&beitragCent=${gruppe.betragCent}&rhythmus=${gruppe.rhythmus}`}
>
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
<AlsGeklaertAbhaken
betreuteId={betreute.id}
schluessel={gruppe.schluessel}
anzeigename={gruppe.anzeigename}
/>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* --- Einzelne groessere Zahlungen ------------------------------ */}
{bild.einzelne.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Einzelne größere Zahlungen</CardTitle>
<CardDescription>
Kein Vertragsverdacht, sondern eine Merkliste. Die Gruppierung oben braucht
mindestens zwei Zahlungen — eine jährlich abgebuchte Prämie erscheint bei
einem importierten Jahr aber nur einmal. Ausgerechnet Versicherungen werden
oft jährlich gezahlt, deshalb steht hier alles ab{' '}
{formatCent(centZuZahl(EINZELZAHLUNG_AB_CENT))}.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Vertrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bild.einzelne.map((zahlung) => (
<TableRow key={zahlung.buchungId}>
<TableCell className="font-medium">{zahlung.anzeigename}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDatum(zahlung.datum)}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(zahlung.betragCent)} />
</TableCell>
<TableCell className="text-sm">
{zahlung.vertragBezeichnung ? (
<Badge variant="secondary">{zahlung.vertragBezeichnung}</Badge>
) : (
<span className="text-muted-foreground">keiner</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* --- Bereits erklaert ------------------------------------------ */}
{mitVertrag.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schon einem Vertrag zugeordnet</CardTitle>
<CardDescription>
Erkannt am Namen der Gesellschaft. Steht hier etwas Falsches, stimmt die
Schreibweise am Vertrag nicht mit der auf dem Kontoauszug überein.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{mitVertrag.map((gruppe) => (
<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>
<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>
</CardContent>
</Card>
)}
{/* --- Abgehakt --------------------------------------------------- */}
{bild.abgehakt.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Als „kein Vertrag“ abgehakt</CardTitle>
<CardDescription>
Diese Empfänger bleiben aus der Liste heraus, auch nach dem nächsten Import.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{bild.abgehakt.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
<span className="font-medium">{eintrag.anzeigename}</span>
{eintrag.begruendung && (
<span className="ml-2 text-muted-foreground">
— {eintrag.begruendung}
</span>
)}
</span>
{schreiben && (
<Zurueckholen betreuteId={betreute.id} eintragId={eintrag.id} />
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diese Seite wertet nur aus, sie legt nichts an. Gruppiert wird nach dem Empfänger
aus dem Kontoauszug — Groß- und Kleinschreibung, Satzzeichen und die Rechtsform
werden dabei zusammengefasst. Ähnliche, aber verschiedene Namen bleiben bewusst
getrennt: „Stadtwerke Bamberg“ und „Stadtwerke Bayreuth“ sind zwei Rechnungssteller.
</AlertDescription>
</Alert>
</>
)}
</div>
)
}
@@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({
name: kontakt.name, name: kontakt.name,
organisation: kontakt.organisation, organisation: kontakt.organisation,
rolle: kontakt.rolle, rolle: kontakt.rolle,
anrede: kontakt.anrede,
telefon: kontakt.telefon, telefon: kontakt.telefon,
mobil: kontakt.mobil, mobil: kontakt.mobil,
fax: kontakt.fax, fax: kontakt.fax,
@@ -0,0 +1,89 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { anschriftSpeichern, type AnschriftZustand } from '@/server/actions/konto'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Anschrift speichern'}
</Button>
)
}
export function AnschriftFormular({
strasse,
plz,
ort,
}: {
strasse: string | null
plz: string | null
ort: string | null
}) {
const [zustand, aktion] = useActionState<AnschriftZustand, FormData>(anschriftSpeichern, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input
id="strasse"
name="strasse"
defaultValue={strasse ?? ''}
maxLength={150}
autoComplete="street-address"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input
id="plz"
name="plz"
defaultValue={plz ?? ''}
maxLength={10}
autoComplete="postal-code"
inputMode="numeric"
/>
</Feld>
<Feld
id="ort"
beschriftung="Ort"
erklaerung="Steht außerdem in der Datumszeile: „Bayreuth, den 23.08.2026“."
fehler={zustand.felder?.ort}
>
<Input
id="ort"
name="ort"
defaultValue={ort ?? ''}
maxLength={100}
autoComplete="address-level2"
/>
</Feld>
</div>
<Absenden />
</form>
)
}
+52 -2
View File
@@ -1,14 +1,17 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { KeyRound, ShieldCheck } from 'lucide-react' import { Fingerprint, KeyRound, MapPin, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung' import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { passkeysDesBenutzers, passkeysMoeglich } from '@/lib/auth/passkey'
import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen' import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils' import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular' import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' } export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -16,10 +19,14 @@ export const dynamic = 'force-dynamic'
export default async function KontoSeite() { export default async function KontoSeite() {
const benutzer = await erfordereBenutzer() const benutzer = await erfordereBenutzer()
const codesUebrig = benutzer.wiederherstellungscodes.length const codesUebrig = benutzer.wiederherstellungscodes.length
const passkeys = await passkeysDesBenutzers(benutzer.id)
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." /> <SeitenKopf
titel="Mein Konto"
beschreibung="Zugangsdaten, eigene Anschrift, zweiter Faktor und Passkeys."
/>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -52,6 +59,27 @@ export default async function KontoSeite() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-5" aria-hidden="true" />
Meine Anschrift
</CardTitle>
<CardDescription>
Steht im Absenderblock jedes Schreibens, das hier entsteht, und liefert den Ort für
die Datumszeile. Ohne sie geht ein förmlicher Brief ohne Rücksendeadresse hinaus.
Jede Angabe ist einzeln freiwillig — was fehlt, bleibt im Brief einfach weg.
</CardDescription>
</CardHeader>
<CardContent>
<AnschriftFormular
strasse={benutzer.strasse}
plz={benutzer.plz}
ort={benutzer.ort}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -89,6 +117,28 @@ export default async function KontoSeite() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Fingerprint className="size-5" aria-hidden="true" />
Passkeys
</CardTitle>
<CardDescription>
Anmelden mit Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail, Passwort und Code.
Ein Passkey gilt als zweiter Faktor, weil er Besitz des Geräts und dessen Entsperrung
verlangt. Er ist an diese Adresse gebunden: Auf einer nachgebauten Anmeldeseite
funktioniert er nicht.
</CardDescription>
</CardHeader>
<CardContent>
<PasskeyVerwaltung
passkeys={passkeys}
zweiterFaktorAktiv={Boolean(benutzer.totpAktiviertAm)}
moeglich={passkeysMoeglich()}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
+4 -1
View File
@@ -34,6 +34,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen', ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
ABMELDUNG: 'abgemeldet', ABMELDUNG: 'abgemeldet',
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet', ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
PASSKEY_REGISTRIERT: 'Passkey eingerichtet',
PASSKEY_ENTFERNT: 'Passkey entfernt',
PASSWORT_GEAENDERT: 'Passwort geändert', PASSWORT_GEAENDERT: 'Passwort geändert',
ROLLE_GEAENDERT: 'Rolle geändert', ROLLE_GEAENDERT: 'Rolle geändert',
FREIGABE_GEAENDERT: 'Freigabe geändert', FREIGABE_GEAENDERT: 'Freigabe geändert',
@@ -41,7 +43,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
function variante(aktion: AuditAktion) { function variante(aktion: AuditAktion) {
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot' if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange' if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT' || aktion === 'PASSKEY_ENTFERNT')
return 'orange'
if (aktion === 'ERSTELLT') return 'gruen' if (aktion === 'ERSTELLT') return 'gruen'
return 'secondary' return 'secondary'
} }
+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',
},
})
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { emailFassung, siehtAusWieEmail } from '@/lib/schreiben/email'
import { emlErzeugen } from '@/lib/schreiben/eml'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
/**
* Das Schreiben als .eml-Entwurf herunterladen.
*
* Gleicher Aufbau wie der Word-Download (../docx/route.ts): POST, weil der
* Text mitkommt und nicht in die Adresszeile gehoert; Zugriff wird hier
* geprueft, weil sich eine Route unmittelbar aufrufen laesst.
*/
export const dynamic = 'force-dynamic'
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 betreff = String(formular.get('betreff') ?? '').trim()
const adresse = String(formular.get('empfaengerEmail') ?? '').trim()
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
if (!siehtAusWieEmail(adresse)) {
return NextResponse.json(
{ fehler: 'Für diesen Empfänger ist keine E-Mail-Adresse hinterlegt.' },
{ status: 400 },
)
}
try {
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const fassung = emailFassung(text, betreff)
const datei = emlErzeugen({ an: adresse, betreff: fassung.betreff, text: fassung.text })
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, heuteAlsTag(), 'eml')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': 'message/rfc822',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+149
View File
@@ -0,0 +1,149 @@
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'
import { VorlagenWahl } from '@/components/schreiben/vorlagen-wahl'
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,
benutzer,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
vorlageId: text('vorlageId') || 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 vorlagen =
art === 'FREI'
? await db.schreibvorlage.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
: []
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: {
id: true,
name: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
email: 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' }]}
/>
{art === 'FREI' && vorlagen.length > 0 && (
<VorlagenWahl vorlagen={vorlagen} aktiveVorlageId={text('vorlageId') || null} />
)}
<SchreibenEditor
// Der Editor haelt den Text als eigenen State. Wechselt die Vorlage,
// muss er neu aufsetzen - sonst ueberlebt der alte Entwurf die
// Navigation und die Vorlage kommt nie im Textfeld an.
key={text('vorlageId') || 'ohne-vorlage'}
betreuteId={aktive.id}
art={art}
entwurf={{
betreff: entwurf.betreff,
text: entwurf.text,
empfaengerName: entwurf.empfaengerName,
empfaengerAnschrift: entwurf.empfaengerAnschrift,
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>
)
}
@@ -1,149 +1,149 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag' import { betragFormatieren } from '@/lib/finanzen/betrag'
import { import {
fristAmpel, fristAmpel,
rechtsbehelfsfristBerechnen, rechtsbehelfsfristBerechnen,
zugangsvermutung, zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf' } from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe' import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Bescheid-Prüfung. * Bescheid-Prüfung.
* *
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist. * können, wo die Rechnung abgebogen ist.
*/ */
export default async function BescheidSeite({ export default async function BescheidSeite({
params, params,
}: { }: {
params: Promise<{ antragId: string; bescheidId: string }> params: Promise<{ antragId: string; bescheidId: string }>
}) { }) {
const { antragId, bescheidId } = await params const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({ const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
}) })
if (!bescheid) notFound() if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid) const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({ const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm, zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom, bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate, fristMonate: bescheid.rechtsbehelfsfristMonate,
heute, heute,
}) })
const vorschlag = zugangsvermutung(bescheid.bescheidVom) const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`} titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={ beschreibung={
<> <>
{bescheid.antrag.traeger} {bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)} {formatDatum(bescheid.zugegangenAm)}
</> </>
} }
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' }, { titel: 'Bescheid' },
]} ]}
/> />
<BescheidPruefung <BescheidPruefung
bescheidId={bescheid.id} bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus} pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={ widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : '' bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
} }
darfSchreiben={darfSchreiben} darfSchreiben={darfSchreiben}
rechenweg={{ rechenweg={{
berechenbar: ergebnis.berechenbar, berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis) ? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null, : null,
rechnerischesEnde: ergebnis.rechnerischesEnde rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde) ? alsIsoTag(ergebnis.rechnerischesEnde)
: null, : null,
verschoben: ergebnis.verschoben, verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend, tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen, abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis), ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte, schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise, hinweise: ergebnis.hinweise,
}} }}
/> />
{offen.notizen && ( {offen.notizen && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Notizen</CardTitle> <CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription> <CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{darfSchreiben && ( {darfSchreiben && (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2> <h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular <BescheidFormular
antragId={antragId} antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }} zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{ werte={{
id: bescheid.id, id: bescheid.id,
art: bescheid.art, art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom), bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '', zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '', zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '', zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent), betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate, rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm) ? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '', : '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
<div className="border-t pt-6"> <div className="border-t pt-6">
<AktionMitRueckfrage <AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)} aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage." frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline" variant="outline"
size="default" size="default"
> >
<Trash2 aria-hidden="true" /> <Trash2 aria-hidden="true" />
Bescheid löschen Bescheid löschen
</AktionMitRueckfrage> </AktionMitRueckfrage>
</div> </div>
</section> </section>
)} )}
</div> </div>
) )
} }
+232 -232
View File
@@ -1,232 +1,232 @@
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils' import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Vermögens-Bewertungsansicht. * Vermögens-Bewertungsansicht.
* *
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/ */
export default async function VermoegensbewertungSeite() { export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([ const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({ db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht }, where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
db.vertrag.findMany({ db.vertrag.findMany({
where: { where: {
betreuteId, betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein // Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen. // Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] }, art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht, ...nichtGeloescht,
}, },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
]) ])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin. // zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set( const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null), posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
) )
const zeilen: ListenPosten[] = [ const zeilen: ListenPosten[] = [
...posten.map((p) => { ...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p) const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0] const stand = p.wertstaende[0]
return { return {
id: p.id, id: p.id,
bezeichnung: p.bezeichnung, bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const, herkunft: 'VERMOEGEN' as const,
art: p.art, art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art], artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe, bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null, wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null, zusatz: stand ? stand.wertquelle : null,
} }
}), }),
...vertraege ...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id)) .filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => { .map((v) => {
const geoeffnet = oeffnen('Vertrag', v) const geoeffnet = oeffnen('Vertrag', v)
return { return {
id: v.id, id: v.id,
bezeichnung: v.bezeichnung, bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const, herkunft: 'VERTRAG' as const,
art: v.art, art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art], artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe, bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen. // als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null, wertCent: null,
wertStichtag: null, wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft, zusatz: v.gesellschaft,
} }
}), }),
] ]
const uebersicht = uebersichtBilden( const uebersicht = uebersichtBilden(
zeilen.map((z) => ({ zeilen.map((z) => ({
id: z.id, id: z.id,
bezeichnung: z.bezeichnung, bezeichnung: z.bezeichnung,
herkunft: z.herkunft, herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung, artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung, bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent), wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null, wertStichtag: null,
begruendung: z.begruendung, begruendung: z.begruendung,
href: '#', href: '#',
})), })),
) )
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Vermögen bewerten" titel="Vermögen bewerten"
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter." beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' }, { titel: 'Vermögen' },
]} ]}
/> />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR} titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent} betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl} anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert} ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot" farbe="text-ampel-rot"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT} titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent} betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl} anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert} ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen" farbe="text-ampel-gruen"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG} titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent} betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl} anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert} ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange" farbe="text-ampel-orange"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT} titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent} betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl} anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert} ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground" farbe="text-muted-foreground"
/> />
</div> </div>
<Card> <Card>
<CardContent className="space-y-2 pt-5"> <CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span> </span>
<span className="text-lg font-semibold tabular-nums"> <span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)} {formatCent(vergleich.freibetragCent)}
</span> </span>
</div> </div>
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span> <span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
<span <span
className={`text-lg font-semibold tabular-nums ${ className={`text-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen' vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`} }`}
> >
{formatCent(vergleich.ueberFreibetragCent)} {formatCent(vergleich.ueberFreibetragCent)}
</span> </span>
</div> </div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p> <p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent> </CardContent>
</Card> </Card>
{uebersicht.ungeprueft.length > 0 && ( {uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle> <AlertTitle>
{uebersicht.ungeprueft.length}{' '} {uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft {uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen. kostet Wochen.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{uebersicht.ohneBegruendung.length > 0 && ( {uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle> <AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription> <AlertDescription>
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf {uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist. sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} /> <Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div> </div>
) )
} }
function Summenkachel({ function Summenkachel({
titel, titel,
betrag, betrag,
anzahl, anzahl,
ohneWert, ohneWert,
farbe, farbe,
}: { }: {
titel: string titel: string
betrag: bigint betrag: bigint
anzahl: number anzahl: number
ohneWert: number ohneWert: number
farbe: string farbe: string
}) { }) {
return ( return (
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p> <p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p> <p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
@@ -1,77 +1,77 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular' import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */ /** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string { function centAlsText(cent: bigint | null): string {
if (cent === null) return '' if (cent === null) return ''
const negativ = cent < 0n const negativ = cent < 0n
const betrag = negativ ? -cent : cent const betrag = negativ ? -cent : cent
const euro = betrag / 100n const euro = betrag / 100n
const rest = betrag % 100n const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}` return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
} }
export default async function VertragBearbeiten({ export default async function VertragBearbeiten({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`) if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`} titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert." beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' }, { titel: 'Bearbeiten' },
]} ]}
/> />
<VertragFormular <VertragFormular
betreuteId={aktive.id} betreuteId={aktive.id}
werte={{ werte={{
id: vertrag.id, id: vertrag.id,
bezeichnung: vertrag.bezeichnung, bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '', gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art, art: vertrag.art,
status: vertrag.status, status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '', vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '', beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '', endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '', kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '', kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '', gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent), beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '', rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe, bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '', bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
</div> </div>
) )
} }
+96 -96
View File
@@ -1,96 +1,96 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent' import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent' import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung' import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({ export default async function VertragKuendigen({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE') const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const paket = kuendigungsassistent({ const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }), kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer, vertragsnummer: offen.vertragsnummer,
art: vertrag.art, art: vertrag.art,
laufzeit: { laufzeit: {
beginnAm: vertrag.beginnAm, beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm, endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
}, },
heute, heute,
}) })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel="Kündigungs-Assistent" titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`} beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' }, { titel: 'Kündigen' },
]} ]}
/> />
<KuendigungsAssistent <KuendigungsAssistent
betreuteId={aktive.id} betreuteId={aktive.id}
vertragId={id} vertragId={id}
darfSchreiben={schreiben} darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)} heuteIso={alsIsoTag(heute)}
daten={{ daten={{
schreiben: paket.schreiben, schreiben: paket.schreiben,
warnungen: paket.warnungen, warnungen: paket.warnungen,
genehmigung: paket.genehmigung, genehmigung: paket.genehmigung,
versandnachweis: { versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten], moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis, hinweis: paket.versandnachweis.hinweis,
}, },
plan: { plan: {
berechenbar: paket.plan.berechenbar, berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin) ? alsIsoTag(paket.plan.naechsterTermin)
: null, : null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm) ? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null, : null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag) ? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null, : null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag, tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst, verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise, hinweise: paket.plan.hinweise,
}, },
}} }}
/> />
</div> </div>
) )
} }
+275 -266
View File
@@ -1,266 +1,275 @@
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } from 'lucide-react' import { FileX2, Pencil, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular' import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert' import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { import {
kuendigungsampel, kuendigungsampel,
kuendigungsplan, kuendigungsplan,
laufzeitAusVertrag, laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert' import {
import { anfrageStatus,
BEWERTUNG_BESCHRIFTUNG, rueckkaufswertAnschreiben,
BEWERTUNG_ERKLAERUNG, wertHistorie,
jahresbeitragCent, } from '@/lib/vertraege/rueckkaufswert'
kannRueckkaufswertHaben, import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
RHYTHMUS_BESCHRIFTUNG, import {
VERTRAG_ART_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG,
} from '@/lib/vertraege/konstanten' jahresbeitragCent,
import { formatCent, formatDatum } from '@/lib/utils' kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG,
export const dynamic = 'force-dynamic' VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) { } from '@/lib/vertraege/konstanten'
const { id } = await params import { formatCent, formatDatum } from '@/lib/utils'
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer) export const dynamic = 'force-dynamic'
if (!aktive) return null
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { id } = await params
const schreiben = darfSchreiben('VERTRAEGE') const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer)
const vertrag = await db.vertrag.findFirst({ if (!aktive) return null
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
vermoegensposten: { const schreiben = darfSchreiben('VERTRAEGE')
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, const vertrag = await db.vertrag.findFirst({
}, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}, include: {
}) // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
if (!vertrag) notFound() vermoegensposten: {
where: nichtGeloescht,
const offen = oeffnen('Vertrag', vertrag) include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
const heute = heuteAlsTag() },
},
const plan = kuendigungsplan({ })
beginnAm: vertrag.beginnAm, if (!vertrag) notFound()
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), const offen = oeffnen('Vertrag', vertrag)
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, const heute = heuteAlsTag()
heute,
}) const plan = kuendigungsplan({
const ampel = kuendigungsampel(plan) beginnAm: vertrag.beginnAm,
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute) endeAm: vertrag.endeAm,
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
const kopf = briefkopfBauen({ betreute: aktive, benutzer }) heute,
const anschreiben = rueckkaufswertAnschreiben({ })
kopf, const ampel = kuendigungsampel(plan)
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
vertragsnummer: offen.vertragsnummer,
stichtag: heute, const kopf = briefkopfBauen({ betreute: aktive, benutzer })
heute, const anschreiben = rueckkaufswertAnschreiben({
}) kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? [] vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
return ( stichtag: heute,
<div className="space-y-6"> heute,
<SeitenKopf })
titel={vertrag.bezeichnung}
beschreibung={ const wertstaende = wertHistorie(vertrag.vermoegensposten)
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean) return (
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art] <div className="space-y-6">
} <SeitenKopf
pfad={[ titel={vertrag.bezeichnung}
{ titel: 'Übersicht', href: '/heute' }, beschreibung={
{ titel: 'Verträge', href: '/vertraege' }, [vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
{ titel: vertrag.bezeichnung }, .filter(Boolean)
]} .join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
aktionen={ }
schreiben && ( pfad={[
<div className="flex flex-wrap gap-2"> { titel: 'Übersicht', href: '/heute' },
<Button asChild variant="outline"> { titel: 'Verträge', href: '/vertraege' },
<Link href={`/vertraege/${id}/kuendigen`}> { titel: vertrag.bezeichnung },
<FileX2 aria-hidden="true" /> ]}
Kündigen aktionen={
</Link> schreiben && (
</Button> <div className="flex flex-wrap gap-2">
<Button asChild> <Button asChild variant="outline">
<Link href={`/vertraege/${id}/bearbeiten`}> <Link href={`/vertraege/${id}/kuendigen`}>
<Pencil aria-hidden="true" /> <FileX2 aria-hidden="true" />
Bearbeiten Kündigen
</Link> </Link>
</Button> </Button>
</div> <Button asChild>
) <Link href={`/vertraege/${id}/bearbeiten`}>
} <Pencil aria-hidden="true" />
/> Bearbeiten
</Link>
<Card> </Button>
<CardHeader> </div>
<CardTitle className="flex items-center gap-2"> )
<Shield className="size-5" aria-hidden="true" /> }
Vertragsdaten />
</CardTitle>
<CardDescription> <Card>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt. <CardHeader>
</CardDescription> <CardTitle className="flex items-center gap-2">
</CardHeader> <Shield className="size-5" aria-hidden="true" />
<CardContent className="grid gap-x-8 sm:grid-cols-2"> Vertragsdaten
<dl className="divide-y"> </CardTitle>
<Datenzeile beschriftung="Vertragsnummer"> <CardDescription>
{offen.vertragsnummer ?? '–'} Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</Datenzeile> </CardDescription>
<Datenzeile beschriftung="Status"> </CardHeader>
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]} <CardContent className="grid gap-x-8 sm:grid-cols-2">
</Datenzeile> <dl className="divide-y">
<Datenzeile beschriftung="Laufzeitbeginn"> <Datenzeile beschriftung="Vertragsnummer">
{formatDatum(vertrag.beginnAm)} {offen.vertragsnummer ?? '–'}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende"> <Datenzeile beschriftung="Status">
{formatDatum(vertrag.endeAm)} {VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist"> <Datenzeile beschriftung="Laufzeitbeginn">
{vertrag.kuendigungsfristMonate !== null {formatDatum(vertrag.beginnAm)}
? `${vertrag.kuendigungsfristMonate} Monate` </Datenzeile>
: 'nicht erfasst'} <Datenzeile beschriftung="Vereinbartes Ende">
</Datenzeile> {formatDatum(vertrag.endeAm)}
</dl> </Datenzeile>
<dl className="divide-y"> <Datenzeile beschriftung="Kündigungsfrist">
<Datenzeile beschriftung="Beitrag"> {vertrag.kuendigungsfristMonate !== null
{formatCent(vertrag.beitragCent)} ? `${vertrag.kuendigungsfristMonate} Monate`
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''} : 'nicht erfasst'}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile> </dl>
<Datenzeile beschriftung="Gekündigt am"> <dl className="divide-y">
{formatDatum(vertrag.gekuendigtAm)} <Datenzeile beschriftung="Beitrag">
</Datenzeile> {formatCent(vertrag.beitragCent)}
<Datenzeile beschriftung="Gekündigt zum"> {vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
{formatDatum(vertrag.kuendigungZum)} </Datenzeile>
</Datenzeile> <Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt"> <Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)} {formatDatum(vertrag.gekuendigtAm)}
</Datenzeile> </Datenzeile>
</dl> <Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)}
{offen.notizen && ( </Datenzeile>
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2"> <Datenzeile beschriftung="Rückkaufswert angefragt">
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> {formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</div> </Datenzeile>
)} </dl>
</CardContent>
</Card> {offen.notizen && (
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
<Card> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
<CardHeader> </div>
<CardTitle>Kündigungsfrist</CardTitle> )}
<CardDescription> </CardContent>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der </Card>
Absendetag früher als der Fristablauf.
</CardDescription> <Card>
</CardHeader> <CardHeader>
<CardContent className="space-y-4"> <CardTitle>Kündigungsfrist</CardTitle>
{plan.berechenbar ? ( <CardDescription>
<> Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
<dl className="grid gap-3 sm:grid-cols-3"> Absendetag früher als der Fristablauf.
<div className="rounded-md border p-3"> </CardDescription>
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt> </CardHeader>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd> <CardContent className="space-y-4">
</div> {plan.berechenbar ? (
<div className="rounded-md border p-3"> <>
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt> <dl className="grid gap-3 sm:grid-cols-3">
<dd className="text-lg font-semibold"> <div className="rounded-md border p-3">
{formatDatum(plan.zugangSpaetestensAm)} <dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
</dd> <dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div> </div>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt> <dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)} {formatDatum(plan.zugangSpaetestensAm)}
</dd> </dd>
{ampel && ( </div>
<dd className="mt-1"> <div className="rounded-md border p-3">
<Badge variant={ampel.variante}>{ampel.text}</Badge> <dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
</dd> <dd className="text-lg font-semibold">
)} {formatDatum(plan.spaetesterAbsendetag)}
</div> </dd>
</dl> {ampel && (
<ul className="space-y-1.5 text-sm text-muted-foreground"> <dd className="mt-1">
{plan.hinweise.map((hinweis) => ( <Badge variant={ampel.variante}>{ampel.text}</Badge>
<li key={hinweis}>{hinweis}</li> </dd>
))} )}
</ul> </div>
</> </dl>
) : ( <ul className="space-y-1.5 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground"> {plan.hinweise.map((hinweis) => (
{plan.hinweise[0] ?? <li key={hinweis}>{hinweis}</li>
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'} ))}
</p> </ul>
)} </>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">
{plan.hinweise[0] ??
{kannRueckkaufswertHaben(vertrag.art) && ( 'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
<Rueckkaufswert </p>
betreuteId={aktive.id} )}
vertragId={id} </CardContent>
darfSchreiben={schreiben} </Card>
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig} {kannRueckkaufswertHaben(vertrag.art) && (
tageSeitAnfrage={anfrage.tageSeitAnfrage} <Rueckkaufswert
anschreiben={anschreiben} betreuteId={aktive.id}
stichtagVorschlag={alsIsoTag(heute)} vertragId={id}
werte={wertstaende.map((w) => ({ darfSchreiben={schreiben}
id: w.id, angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
stichtag: w.stichtag.toISOString(), erinnerungFaellig={anfrage.erinnerungFaellig}
wertCent: centZuZahl(w.wertCent) ?? 0, tageSeitAnfrage={anfrage.tageSeitAnfrage}
wertquelle: w.wertquelle, anschreiben={anschreiben}
bemerkung: w.bemerkung, stichtagVorschlag={alsIsoTag(heute)}
}))} alterHinweis={
/> wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
)} }
werte={wertstaende.map((w) => ({
<Card> id: w.id,
<CardHeader> stichtag: w.stichtag.toISOString(),
<CardTitle>Bewertung fürs Sozialamt</CardTitle> wertCent: centZuZahl(w.wertCent) ?? 0,
<CardDescription> wertquelle: w.wertquelle,
Meine Einordnung und meine Begründung — keine Rechtsauskunft. bemerkung: w.bemerkung,
</CardDescription> }))}
</CardHeader> />
<CardContent className="space-y-3"> )}
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground"> <Card>
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]} <CardHeader>
</p> <CardTitle>Bewertung fürs Sozialamt</CardTitle>
{offen.bewertungBegruendung ? ( <CardDescription>
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm"> Meine Einordnung und meine Begründung — keine Rechtsauskunft.
{offen.bewertungBegruendung} </CardDescription>
</p> </CardHeader>
) : ( <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer <p className="text-sm text-muted-foreground">
Aufstellung an den Bezirk auftaucht. {BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p> </p>
)} {offen.bewertungBegruendung ? (
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
</CardContent> {offen.bewertungBegruendung}
</Card> </p>
</div> ) : (
) <p className="text-sm text-muted-foreground">
} Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht.
</p>
)}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent>
</Card>
</div>
)
}
+66 -2
View File
@@ -1,11 +1,62 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular' import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { alsZahlRhythmus, type Rhythmus } from '@/lib/finanzen/wiederkehrend'
import { betragFormatieren } from '@/lib/finanzen/betrag'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function VertragAnlegen() { /**
* Uebernimmt eine Vorbelegung aus der Adresszeile.
*
* Sie kommt von "Wiederkehrende Zahlungen": Dort steht schon, wer da
* abbucht und wie viel. Diese Angaben noch einmal abzutippen waere genau
* die Arbeit, die die Auswertung ersparen soll.
*
* Uebernommen wird nur, was aus dem Kontoauszug ablesbar ist - Gesellschaft,
* Beitrag, Rhythmus. Die Bezeichnung und die Vertragsart bleiben leer: Was
* fuer ein Vertrag das ist, steht nicht im Auszug, und eine geratene
* Vorbelegung wird uebersehen statt korrigiert.
*/
function vorbelegung(parameter: Record<string, string | string[] | undefined>) {
const text = (name: string): string => {
const wert = parameter[name]
return typeof wert === 'string' ? wert.trim() : ''
}
const werte = { ...LEERER_VERTRAG }
let etwasUebernommen = false
const gesellschaft = text('gesellschaft')
if (gesellschaft) {
werte.gesellschaft = gesellschaft.slice(0, 200)
etwasUebernommen = true
}
const cent = text('beitragCent')
if (/^[0-9]{1,15}$/.test(cent)) {
werte.beitrag = betragFormatieren(BigInt(cent))
etwasUebernommen = true
}
const rhythmus = alsZahlRhythmus(text('rhythmus') as Rhythmus)
if (rhythmus) {
werte.rhythmus = rhythmus as typeof werte.rhythmus
etwasUebernommen = true
}
return { werte, etwasUebernommen }
}
export default async function VertragAnlegen({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const benutzer = await erfordereBenutzer('/vertraege/neu') const benutzer = await erfordereBenutzer('/vertraege/neu')
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
@@ -13,6 +64,8 @@ export default async function VertragAnlegen() {
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege') if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
const { werte, etwasUebernommen } = vorbelegung(parameter)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
@@ -25,7 +78,18 @@ export default async function VertragAnlegen() {
]} ]}
/> />
<VertragFormular betreuteId={aktive.id} werte={LEERER_VERTRAG} /> {etwasUebernommen && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
Gesellschaft, Beitrag und Rhythmus sind aus den wiederkehrenden Zahlungen
übernommen. Bezeichnung und Art musst du selbst wählen — was für ein Vertrag das
ist, steht nicht im Kontoauszug.
</AlertDescription>
</Alert>
)}
<VertragFormular betreuteId={aktive.id} werte={werte} />
</div> </div>
) )
} }
+260 -235
View File
@@ -1,235 +1,260 @@
import Link from 'next/link' import Link from 'next/link'
import { Plus, Shield } from 'lucide-react' import { FileArchive, Plus, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { import {
KuendigungsUebersicht, KuendigungsUebersicht,
type Kuendigungswarnung, type Kuendigungswarnung,
} from '@/components/vertraege/kuendigungs-uebersicht' } from '@/components/vertraege/kuendigungs-uebersicht'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { heuteAlsTag } from '@/lib/fristen/datum' import { heuteAlsTag } from '@/lib/fristen/datum'
import { import {
kuendigungsampel, kuendigungsampel,
kuendigungsplan, kuendigungsplan,
laufzeitAusVertrag, laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
jahresbeitragCent, jahresbeitragCent,
RHYTHMUS_BESCHRIFTUNG, kannRueckkaufswertHaben,
VERTRAG_ART_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' VERTRAG_STATUS_BESCHRIFTUNG,
import { formatCent, formatDatum } from '@/lib/utils' } from '@/lib/vertraege/konstanten'
import { juengsterWert } from '@/lib/vertraege/rueckkaufswert'
export const dynamic = 'force-dynamic' import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import { formatCent, formatDatum } from '@/lib/utils'
const BEWERTUNG_VARIANTE = {
UNGEPRUEFT: 'outline', export const dynamic = 'force-dynamic'
VERWERTBAR: 'orange',
GESCHUETZT: 'gruen', const BEWERTUNG_VARIANTE = {
STRITTIG: 'gelb', UNGEPRUEFT: 'outline',
} as const VERWERTBAR: 'orange',
GESCHUETZT: 'gruen',
export default async function VertraegeUebersicht() { STRITTIG: 'gelb',
const benutzer = await erfordereBenutzer('/vertraege') } as const
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null export default async function VertraegeUebersicht() {
const benutzer = await erfordereBenutzer('/vertraege')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const aktive = await aktiveBetreute(benutzer)
const schreiben = darfSchreiben('VERTRAEGE') if (!aktive) return null
const vertraege = await db.vertrag.findMany({ const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
where: { betreuteId: aktive.id, ...nichtGeloescht }, const schreiben = darfSchreiben('VERTRAEGE')
include: {
vermoegensposten: { const vertraege = await db.vertrag.findMany({
where: { art: 'VERSICHERUNG', ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: {
}, // Ueber die Verknuepfung, nicht ueber die Art: Ein von Hand als
}, // "Sonstiges" angelegter Posten meint denselben Vertrag.
orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }], vermoegensposten: {
}) where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
const heute = heuteAlsTag() },
const warnungen: Kuendigungswarnung[] = [] },
let jahresbeitragSumme = 0n orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }],
})
const zeilen = vertraege.map((vertrag) => {
const offen = oeffnen('Vertrag', vertrag) const heute = heuteAlsTag()
const plan = kuendigungsplan({ const warnungen: Kuendigungswarnung[] = []
beginnAm: vertrag.beginnAm, let jahresbeitragSumme = 0n
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), const zeilen = vertraege.map((vertrag) => {
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, const offen = oeffnen('Vertrag', vertrag)
heute, const plan = kuendigungsplan({
}) beginnAm: vertrag.beginnAm,
const ampel = kuendigungsampel(plan) endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr heute,
})
const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI' const ampel = kuendigungsampel(plan)
if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) {
warnungen.push({ const letzterWert = juengsterWert(vertrag.vermoegensposten)
vertragId: vertrag.id,
bezeichnung: vertrag.bezeichnung, const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null, if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
tageBisAbsendetag: plan.tageBisAbsendetag,
verpasst: plan.verpasst, const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI'
variante: ampel.variante, if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) {
text: ampel.text, warnungen.push({
}) vertragId: vertrag.id,
} bezeichnung: vertrag.bezeichnung,
absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null,
return { tageBisAbsendetag: plan.tageBisAbsendetag,
id: vertrag.id, verpasst: plan.verpasst,
bezeichnung: vertrag.bezeichnung, variante: ampel.variante,
gesellschaft: vertrag.gesellschaft, text: ampel.text,
art: vertrag.art, })
status: vertrag.status, }
vertragsnummer: offen.vertragsnummer,
beitragCent: centZuZahl(vertrag.beitragCent), return {
rhythmus: vertrag.rhythmus, id: vertrag.id,
bewertung: vertrag.bewertungSozialhilfe, bezeichnung: vertrag.bezeichnung,
rueckkaufswertCent: centZuZahl( gesellschaft: vertrag.gesellschaft,
vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, art: vertrag.art,
), status: vertrag.status,
rueckkaufswertStichtag: vertragsnummer: offen.vertragsnummer,
vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null, beitragCent: centZuZahl(vertrag.beitragCent),
ampel, rhythmus: vertrag.rhythmus,
} bewertung: vertrag.bewertungSozialhilfe,
}) rueckkaufswertCent: centZuZahl(letzterWert?.wertCent ?? null),
rueckkaufswertStichtag: letzterWert?.stichtag.toISOString() ?? null,
return ( rueckkaufswertAlter: wertalterBestimmen(letzterWert?.stichtag ?? null, heute),
<div className="space-y-6"> wertErwartet: kannRueckkaufswertHaben(vertrag.art),
<SeitenKopf ampel,
titel="Versicherungen & Verträge" }
beschreibung="Alle laufenden Verträge mit Beitrag, Kündigungsfrist, Rückkaufswert und der Einordnung fürs Sozialamt." })
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
aktionen={ return (
schreiben && ( <div className="space-y-6">
<Button asChild> <SeitenKopf
<Link href="/vertraege/neu"> titel="Versicherungen & Verträge"
<Plus aria-hidden="true" /> beschreibung="Alle laufenden Verträge mit Beitrag, Kündigungsfrist, Rückkaufswert und der Einordnung fürs Sozialamt."
Vertrag anlegen pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Verträge' }]}
</Link> aktionen={
</Button> schreiben && (
) <>
} <Button asChild variant="outline">
/> <Link href="/vertraege/serienabfrage/seite">
<FileArchive aria-hidden="true" />
<KuendigungsUebersicht warnungen={warnungen} /> Rückkaufswerte anfragen
</Link>
{vertraege.length === 0 ? ( </Button>
<Leerzustand <Button asChild>
titel="Noch kein Vertrag erfasst" <Link href="/vertraege/neu">
beschreibung="Nimm die Kontoauszüge des letzten Jahres zur Hand: Jede wiederkehrende Abbuchung ist ein Vertrag. Das ist der zuverlässigste Weg, nichts zu übersehen — Policen liegen selten vollständig im Ordner." <Plus aria-hidden="true" />
icon={Shield} Vertrag anlegen
aktion={ </Link>
schreiben && ( </Button>
<Button asChild> </>
<Link href="/vertraege/neu"> )
<Plus aria-hidden="true" /> }
Vertrag anlegen />
</Link>
</Button> <KuendigungsUebersicht warnungen={warnungen} />
)
} {vertraege.length === 0 ? (
/> <Leerzustand
) : ( titel="Noch kein Vertrag erfasst"
<Card> beschreibung="Nimm die Kontoauszüge des letzten Jahres zur Hand: Jede wiederkehrende Abbuchung ist ein Vertrag. Das ist der zuverlässigste Weg, nichts zu übersehen — Policen liegen selten vollständig im Ordner."
<CardHeader> icon={Shield}
<CardTitle>Alle Verträge</CardTitle> aktion={
<CardDescription> schreiben && (
Jahresbeitrag der aktiven Verträge zusammen:{' '} <Button asChild>
<strong className="text-foreground"> <Link href="/vertraege/neu">
{formatCent(centZuZahl(jahresbeitragSumme))} <Plus aria-hidden="true" />
</strong> Vertrag anlegen
</CardDescription> </Link>
</CardHeader> </Button>
<CardContent> )
<Table> }
<TableHeader> />
<TableRow> ) : (
<TableHead>Vertrag</TableHead> <Card>
<TableHead>Art</TableHead> <CardHeader>
<TableHead className="text-right">Beitrag</TableHead> <CardTitle>Alle Verträge</CardTitle>
<TableHead className="text-right">Rückkaufswert</TableHead> <CardDescription>
<TableHead>Kündigung</TableHead> Jahresbeitrag der aktiven Verträge zusammen:{' '}
<TableHead>Sozialamt</TableHead> <strong className="text-foreground">
<TableHead>Status</TableHead> {formatCent(centZuZahl(jahresbeitragSumme))}
</TableRow> </strong>
</TableHeader> </CardDescription>
<TableBody> </CardHeader>
{zeilen.map((zeile) => ( <CardContent>
<TableRow key={zeile.id}> <Table>
<TableCell> <TableHeader>
<Link <TableRow>
href={`/vertraege/${zeile.id}`} <TableHead>Vertrag</TableHead>
className="font-medium hover:underline" <TableHead>Art</TableHead>
> <TableHead className="text-right">Beitrag</TableHead>
{zeile.bezeichnung} <TableHead className="text-right">Rückkaufswert</TableHead>
</Link> <TableHead>Kündigung</TableHead>
<span className="block text-sm text-muted-foreground"> <TableHead>Sozialamt</TableHead>
{[zeile.gesellschaft, zeile.vertragsnummer].filter(Boolean).join(' · ') || <TableHead>Status</TableHead>
'Gesellschaft nicht erfasst'} </TableRow>
</span> </TableHeader>
</TableCell> <TableBody>
<TableCell className="text-sm"> {zeilen.map((zeile) => (
{VERTRAG_ART_BESCHRIFTUNG[zeile.art]} <TableRow key={zeile.id}>
</TableCell> <TableCell>
<TableCell className="text-right tabular-nums"> <Link
{formatCent(zeile.beitragCent)} href={`/vertraege/${zeile.id}`}
{zeile.rhythmus && ( className="font-medium hover:underline"
<span className="block text-xs text-muted-foreground"> >
{RHYTHMUS_BESCHRIFTUNG[zeile.rhythmus]} {zeile.bezeichnung}
</span> </Link>
)} <span className="block text-sm text-muted-foreground">
</TableCell> {[zeile.gesellschaft, zeile.vertragsnummer].filter(Boolean).join(' · ') ||
<TableCell className="text-right tabular-nums"> 'Gesellschaft nicht erfasst'}
{formatCent(zeile.rueckkaufswertCent)} </span>
{zeile.rueckkaufswertStichtag && ( </TableCell>
<span className="block text-xs text-muted-foreground"> <TableCell className="text-sm">
{formatDatum(zeile.rueckkaufswertStichtag)} {VERTRAG_ART_BESCHRIFTUNG[zeile.art]}
</span> </TableCell>
)} <TableCell className="text-right tabular-nums">
</TableCell> {formatCent(zeile.beitragCent)}
<TableCell> {zeile.rhythmus && (
{zeile.ampel ? ( <span className="block text-xs text-muted-foreground">
<Badge variant={zeile.ampel.variante}>{zeile.ampel.text}</Badge> {RHYTHMUS_BESCHRIFTUNG[zeile.rhythmus]}
) : ( </span>
<span className="text-sm text-muted-foreground">nicht berechenbar</span> )}
)} </TableCell>
</TableCell> <TableCell className="text-right tabular-nums">
<TableCell> {zeile.rueckkaufswertStichtag ? (
<Badge variant={BEWERTUNG_VARIANTE[zeile.bewertung]}> <>
{BEWERTUNG_BESCHRIFTUNG[zeile.bewertung]} {formatCent(zeile.rueckkaufswertCent)}
</Badge> <span className="block text-xs text-muted-foreground">
</TableCell> {formatDatum(zeile.rueckkaufswertStichtag)}
<TableCell className="text-sm"> </span>
{VERTRAG_STATUS_BESCHRIFTUNG[zeile.status]} {zeile.rueckkaufswertAlter?.veraltet && (
</TableCell> <Badge variant="gelb" className="mt-1">
</TableRow> {zeile.rueckkaufswertAlter.hinweis}
))} </Badge>
</TableBody> )}
</Table> </>
</CardContent> ) : (
</Card> <span className="text-sm font-normal text-muted-foreground">
)} {zeile.wertErwartet ? 'noch nicht erfasst' : '–'}
</span>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 1850 BGB" /> )}
<p className="text-sm text-muted-foreground"> </TableCell>
Kurz übersetzt: Ob ein Vertrag vor der Sozialhilfe einzusetzen ist, richtet sich nach <TableCell>
§ 90 SGB XII — eine angemessene Bestattungsvorsorge bleibt regelmäßig geschützt. Und {zeile.ampel ? (
bevor eine Lebens- oder Rentenversicherung verwertet wird, gehört die Frage nach der <Badge variant={zeile.ampel.variante}>{zeile.ampel.text}</Badge>
Genehmigung des Betreuungsgerichts (§ 1850 BGB) an den Rechtspfleger, nicht an diese App. ) : (
</p> <span className="text-sm text-muted-foreground">nicht berechenbar</span>
</div> )}
) </TableCell>
} <TableCell>
<Badge variant={BEWERTUNG_VARIANTE[zeile.bewertung]}>
{BEWERTUNG_BESCHRIFTUNG[zeile.bewertung]}
</Badge>
</TableCell>
<TableCell className="text-sm">
{VERTRAG_STATUS_BESCHRIFTUNG[zeile.status]}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 1850 BGB" />
<p className="text-sm text-muted-foreground">
Kurz übersetzt: Ob ein Vertrag vor der Sozialhilfe einzusetzen ist, richtet sich nach
§ 90 SGB XII — eine angemessene Bestattungsvorsorge bleibt regelmäßig geschützt. Und
bevor eine Lebens- oder Rentenversicherung verwertet wird, gehört die Frage nach der
Genehmigung des Betreuungsgerichts (§ 1850 BGB) an den Rechtspfleger, nicht an diese App.
</p>
</div>
)
}
@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { serienlaufDurchfuehren } from '@/lib/vertraege/serienlauf'
import { tagAusText } from '@/lib/finanzen/datum'
/**
* Fuehrt die Serienabfrage durch und liefert das ZIP.
*
* POST wie bei den anderen Downloads: Die Auswahl kommt mit, und der Lauf
* hat Nebenwirkungen (Dokumente, Fristen, Anfragedatum) - ein GET, das
* schreibt, waere eine Falle fuer jeden Vorabruf durch den Browser.
*
* Die Nebenwirkungen passieren VOR der Auslieferung. Faellt danach noch
* etwas beim Herunterladen aus, sind Akte und Fristen trotzdem korrekt -
* und die Schreiben liegen als PDF ohnehin in der Akte.
*/
export const dynamic = 'force-dynamic'
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 stichtagRoh = String(formular.get('stichtag') ?? '').trim()
const vertragIds = formular
.getAll('vertragId')
.map((wert) => String(wert).trim())
.filter(Boolean)
if (!betreuteId || vertragIds.length === 0) {
return NextResponse.json({ fehler: 'Es ist kein Vertrag ausgewählt.' }, { status: 400 })
}
// Obergrenze als Schutz vor Unsinn - real sind es fuenf bis zehn.
if (vertragIds.length > 100) {
return NextResponse.json({ fehler: 'Zu viele Verträge auf einmal.' }, { status: 400 })
}
const stichtag = tagAusText(stichtagRoh)
if (!stichtag) {
return NextResponse.json({ fehler: 'Bitte einen gültigen Stichtag angeben.' }, { status: 400 })
}
let benutzer
try {
const zugang = await erfordereZugang(betreuteId, 'VERTRAEGE', 'schreiben')
benutzer = zugang.benutzer
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const ergebnis = await serienlaufDurchfuehren({
betreuteId,
vertragIds,
stichtag,
benutzer: { id: benutzer.id, name: benutzer.name, email: benutzer.email },
})
return new NextResponse(new Uint8Array(ergebnis.zip), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${ergebnis.dateiname}"`,
'Content-Length': String(ergebnis.zip.length),
'Cache-Control': 'no-store, must-revalidate',
},
})
}
@@ -0,0 +1,92 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Scale } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import {
SerienabfrageFormular,
type SerienZeile,
} from '@/components/vertraege/serienabfrage-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { kandidatenBewerten } from '@/lib/vertraege/serienabfrage'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { heuteAlsTag } from '@/lib/fristen/datum'
export const metadata: Metadata = { title: 'Rückkaufswerte anfragen' }
export const dynamic = 'force-dynamic'
/**
* Die Serienabfrage: alle Rückkaufswerte in einem Zug.
*
* Liegt unter /vertraege/serienabfrage/seite, weil /vertraege/serienabfrage
* die POST-Route ist, die das ZIP liefert. Die Trennung ist Absicht: Die
* Route hat Nebenwirkungen und gehört nicht hinter einen GET.
*/
export default async function SerienabfrageSeite() {
const benutzer = await erfordereBenutzer('/vertraege/serienabfrage/seite')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
const vertraege = await db.vertrag.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: {
id: true,
bezeichnung: true,
gesellschaft: true,
art: true,
status: true,
rueckkaufswertAngefragtAm: true,
},
})
const bewertet = kandidatenBewerten(vertraege)
// Vertraege ohne jede Aussicht auf einen Rueckkaufswert (Strom, Telefon,
// Rundfunk) wuerden die Liste nur verlaengern - sie bleiben draussen.
// Alles andere steht drin, notfalls mit Hindernis.
const zeilen: SerienZeile[] = bewertet
.filter(
(k) => k.moeglich || k.hindernis !== 'Diese Vertragsart bildet keinen Rückkaufswert.',
)
.map((k) => ({
vertragId: k.vertrag.id,
bezeichnung: k.vertrag.bezeichnung,
gesellschaft: k.vertrag.gesellschaft,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[k.vertrag.art],
moeglich: k.moeglich,
hindernis: k.hindernis,
vorausgewaehlt: k.vorausgewaehlt,
}))
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel="Rückkaufswerte anfragen"
beschreibung="Alle Lebens- und Rentenversicherungen in einem Zug — ein ZIP mit einem Schreiben je Gesellschaft, die Nachweise in der Akte, die Wiedervorlagen gesetzt."
pfad={[
{ titel: 'Verträge', href: '/vertraege' },
{ titel: 'Rückkaufswerte anfragen' },
]}
/>
<SerienabfrageFormular
betreuteId={aktive.id}
zeilen={zeilen}
stichtagVorschlag={heuteAlsTag().toISOString().slice(0, 10)}
/>
<Alert variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>
Die Schreiben sind Entwürfe zum Ausdrucken und Unterschreiben — versendet wird nichts
automatisch. Keine Rechtsberatung; bei Fragen zur Verwertungspflicht entscheidet der
Sozialhilfeträger.
</AlertDescription>
</Alert>
</div>
)
}
+60 -1
View File
@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, Clock } from 'lucide-react' import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client' import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -56,6 +56,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } }, verknuepfungen: { include: { dokument: true } },
ansprechpartner: true,
}, },
}) })
if (!vorgang) notFound() if (!vorgang) notFound()
@@ -65,6 +66,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Vorgang', vorgang) const offen = oeffnen('Vorgang', vorgang)
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
// --- Statuswechsel aus dem Audit-Log -------------------------------------- // --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle. // Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
@@ -182,6 +191,12 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
aktenzeichenGericht: aktive.aktenzeichenGericht, aktenzeichenGericht: aktive.aktenzeichenGericht,
betreuungsgericht: aktive.betreuungsgericht, betreuungsgericht: aktive.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner
? {
name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
}
: null,
}) })
: null : null
@@ -245,6 +260,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
darfSchreiben={schreiben} 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} />} {schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</> </>
} }
@@ -277,6 +300,40 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p> <p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</div> </div>
{/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
stehen dabei - sonst schlaegt man sie doch wieder nach. */}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
<>
<p className="font-medium">
<Link
href={`/kontakte/${vorgang.ansprechpartner.id}`}
className="hover:underline"
>
{vorgang.ansprechpartner.name}
</Link>
</p>
{vorgang.ansprechpartner.organisation && (
<p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
</p>
)}
{(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<p className="text-sm text-muted-foreground">
{[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
.filter(Boolean)
.join(' · ')}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-sm text-muted-foreground">Nächster Schritt</p> <p className="text-sm text-muted-foreground">Nächster Schritt</p>
<p className="font-medium"> <p className="font-medium">
@@ -355,6 +412,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
betreuteId={aktive.id} betreuteId={aktive.id}
vorgangId={vorgang.id} vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`} abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{ werte={{
titel: vorgang.titel, titel: vorgang.titel,
kategorie: vorgang.kategorie, kategorie: vorgang.kategorie,
@@ -363,6 +421,7 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
beschreibung: offen.beschreibung ?? null, beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle, gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen, aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich, verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt, naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm, faelligAm: vorgang.faelligAm,
+11
View File
@@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette' import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen' import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben') await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
const auswahl = await searchParams const auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1' const leer = auswahl.leer === '1'
@@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({
betreuteId={aktive.id} betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null} vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge" abbrechenHref="/vorgaenge"
kontakte={kontakte}
werte={{ werte={{
titel: vorlage?.titel ?? '', titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES', kategorie: vorlage?.kategorie ?? 'SONSTIGES',
@@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({
beschreibung: vorlage?.beschreibung ?? null, beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null, gegenstelle: null,
aktenzeichen: null, aktenzeichen: null,
ansprechpartnerId: null,
verantwortlich: benutzer.name, verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null, naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null, faelligAm: null,
+10 -2
View File
@@ -4,6 +4,8 @@ import { ShieldCheck } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung' import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { passkeysMoeglich } from '@/lib/auth/passkey'
import { PasskeyAnmeldung } from '@/components/passkey-anmeldung'
import { AnmeldeFormular } from './formular' import { AnmeldeFormular } from './formular'
export const metadata: Metadata = { title: 'Anmelden' } export const metadata: Metadata = { title: 'Anmelden' }
@@ -32,10 +34,14 @@ export default async function AnmeldenSeite({
<CardTitle>Anmelden</CardTitle> <CardTitle>Anmelden</CardTitle>
<CardDescription> <CardDescription>
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen. E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
{passkeysMoeglich() && ' Oder in einem Schritt mit deinem Passkey.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5">
<AnmeldeFormular weiter={weiter} /> <AnmeldeFormular weiter={weiter} />
{/* Nur wenn der Server ueber https laeuft - sonst gibt der
Browser WebAuthn gar nicht frei. */}
{passkeysMoeglich() && <PasskeyAnmeldung weiter={weiter} />}
</CardContent> </CardContent>
</Card> </Card>
@@ -43,7 +49,9 @@ export default async function AnmeldenSeite({
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" /> <ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p> <p>
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
deshalb Pflicht und lässt sich nicht abschalten. deshalb Pflicht und lässt sich nicht abschalten. Ein Passkey erfüllt ihn ebenfalls: Er
verlangt Fingerabdruck, Gesicht oder Geräte-PIN — und lässt sich, anders als ein
abgetippter Code, nicht auf einer nachgebauten Seite abgreifen.
</p> </p>
</div> </div>
</> </>
+9 -1
View File
@@ -21,8 +21,16 @@ export const dynamic = 'force-dynamic'
export default async function ErsteinrichtungSeite() { export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } }) const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// Die Sperrzeile ist die eigentliche Auskunft darueber, ob eingerichtet
// wurde. Der Benutzerzaehler allein genuegt nicht: Liegt der erste Zugang
// im Papierkorb, stuende Schritt 1 sonst wieder offen.
const sperre = await db.ersteinrichtung.findUnique({
where: { id: 'einmalig' },
select: { id: true },
})
// --- Schritt 1: Es gibt noch keinen Zugang ------------------------------- // --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) { if (anzahlBenutzer === 0 && !sperre) {
return ( return (
<> <>
<Card> <Card>
@@ -61,6 +61,23 @@ export function ZugangFormular() {
/> />
</Feld> </Feld>
<Feld
id="geheimnis"
beschriftung="Einrichtungsgeheimnis"
erklaerung="Der Wert von ERSTEINRICHTUNG_GEHEIMNIS aus der .env. Er verhindert, dass jemand
anders diesen Zugang anlegt, solange die Seite noch offensteht."
fehler={zustand.felder?.geheimnis}
pflicht
>
<Input
name="geheimnis"
type="password"
autoComplete="off"
spellCheck={false}
required
/>
</Feld>
<Feld <Feld
id="passwort" id="passwort"
beschriftung="Passwort" beschriftung="Passwort"
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { Modul } from '@prisma/client' import type { Modul } from '@prisma/client'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { darf } from '@/lib/auth/rollen' import { darf } from '@/lib/auth/rollen'
import { aktuellerBenutzer } from '@/lib/auth/sitzung' import { benutzerFuerRoute } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { typErkennen } from '@/lib/speicher/dateityp' import { typErkennen } from '@/lib/speicher/dateityp'
import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher' import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher'
@@ -55,7 +55,10 @@ function nurText(status: number, meldung: string): NextResponse {
export async function GET( export async function GET(
_anfrage: Request, _anfrage: Request,
{ params }: { params: Promise<{ signatur: string }> }, { params }: { params: Promise<{ signatur: string }> },
): Promise<NextResponse> { // Response statt NextResponse: Die Abweisung ohne zweiten Faktor kommt
// aus benutzerFuerRoute() und ist eine schlichte Response. NextResponse
// erbt davon, die uebrigen Rueckgaben passen also weiterhin.
): Promise<Response> {
const { signatur } = await params const { signatur } = await params
// 1. Signatur und Ablauf // 1. Signatur und Ablauf
@@ -70,9 +73,14 @@ export async function GET(
} }
const schluessel = geprueft.schluessel const schluessel = geprueft.schluessel
// 2. Angemeldet? // 2. Angemeldet - und mit abgeschlossenem zweitem Faktor.
const benutzer = await aktuellerBenutzer() //
if (!benutzer) return nurText(401, 'Bitte melde dich an.') // Frueher stand hier nur die Frage nach der Anmeldung. Damit kam jemand,
// der die TOTP-Einrichtung nie abgeschlossen hatte, an jedes Dokument -
// die MFA-Pflicht galt nur fuer Seiten, nicht fuer diesen Abruf.
const angemeldet = await benutzerFuerRoute()
if (!angemeldet.ok) return angemeldet.antwort
const benutzer = angemeldet.benutzer
// 3. Zugriff auf genau diese betreute Person // 3. Zugriff auf genau diese betreute Person
const betreuteId = betreuteIdAusSchluessel(schluessel) const betreuteId = betreuteIdAusSchluessel(schluessel)
+130 -32
View File
@@ -3,15 +3,19 @@ import Credentials from 'next-auth/providers/credentials'
import type { Rolle } from '@prisma/client' import type { Rolle } from '@prisma/client'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash' import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
import { import {
anmeldungErlaubt, anmeldungErlaubt,
aufMindestdauerWarten, aufMindestdauerWarten,
fehlversucheZuruecksetzen, fehlversucheZuruecksetzen,
sperreVermerken,
versuchFesthalten, versuchFesthalten,
} from '@/lib/auth/ratelimit' } from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
import { anschlussAus } from '@/lib/auth/anschluss'
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
/** /**
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt. * Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
@@ -30,6 +34,8 @@ export type AnmeldeGrund =
| 'gesperrt' | 'gesperrt'
| 'konto-inaktiv' | 'konto-inaktiv'
| 'code-falsch' | 'code-falsch'
/** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */
| 'passkey'
/** /**
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code". * Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
@@ -45,14 +51,28 @@ export class AnmeldeFehler extends CredentialsSignin {
} }
} }
/**
* Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:<grund>"
* kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann.
*/
export class PasskeyAnmeldeFehler extends CredentialsSignin {
code: string
constructor(grund: string) {
super(grund)
this.code = `passkey:${grund}`
}
}
function ipAusAnfrage(anfrage: Request | undefined): string | null { function ipAusAnfrage(anfrage: Request | undefined): string | null {
if (!anfrage) return null if (!anfrage) return null
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim() return anschlussAus(anfrage.headers)
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
} }
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true, // Siehe env.vertraueHost: Ist AUTH_URL gesetzt, wird dem Host-Kopf nicht
// geglaubt. Vorher stand hier fest "true".
trustHost: env.vertraueHost,
secret: process.env.AUTH_SECRET, secret: process.env.AUTH_SECRET,
session: { session: {
@@ -93,7 +113,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// 1. Sperre pruefen, bevor teuer gerechnet wird. // 1. Sperre pruefen, bevor teuer gerechnet wird.
const sperre = await anmeldungErlaubt(email, ip) const sperre = await anmeldungErlaubt(email, ip)
if (sperre.gesperrt) { if (sperre.gesperrt) {
await versuchFesthalten(email, ip, false, 'gesperrt') await sperreVermerken(email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
} }
@@ -124,33 +144,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
throw new AnmeldeFehler('konto-inaktiv') throw new AnmeldeFehler('konto-inaktiv')
} }
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer // 3. Zweiter Faktor. Ist er noch nicht eingerichtet, kommt der
// zwar herein, das App-Layout schickt ihn aber sofort zur // Benutzer nur bis zur Einrichtungsseite - erzwungen in
// Einrichtung - er kann nichts anderes tun. // erfordereBenutzer(), nicht erst im Layout.
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) { if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
const codeOhneTrenner = code.replace(/[\s-]/g, '') const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
let zweiterFaktorOk = false
if (/^\d{6}$/.test(codeOhneTrenner)) { if (!eingeloest.ok) {
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
} else if (codeOhneTrenner.length >= 12) {
// Wiederherstellungscode - wird nach Gebrauch entfernt.
const treffer = await wiederherstellungscodeFinden(
benutzer.wiederherstellungscodes,
code,
)
if (treffer >= 0) {
zweiterFaktorOk = true
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
await db.user.update({
where: { id: benutzer.id },
data: { wiederherstellungscodes: rest },
})
}
}
if (!zweiterFaktorOk) {
await versuchFesthalten(email, ip, false, 'code-falsch') await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({ await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN', aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
@@ -158,7 +158,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
entitaetId: benutzer.id, entitaetId: benutzer.id,
userEmail: email, userEmail: email,
userId: benutzer.id, userId: benutzer.id,
bezeichnung: 'Zweiter Faktor falsch', bezeichnung:
eingeloest.grund === 'wiederverwendet'
? 'Zweiter Faktor bereits verbraucht'
: 'Zweiter Faktor falsch',
}) })
await aufMindestdauerWarten(begonnen) await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('code-falsch') throw new AnmeldeFehler('code-falsch')
@@ -203,6 +206,101 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
} }
}, },
}), }),
/**
* Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort.
*
* Die vollstaendige Pruefung laeuft hier drin und nicht in der Server
* Action davor. Das ist Absicht: Wuerde die Action pruefen und das
* Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen
* Zwischenzustand, dem authorize() glauben muesste. Genau solche
* "war schon geprueft"-Uebergaben sind die Stelle, an der sich
* Anmeldungen faelschen lassen. Hier gibt es sie nicht.
*/
Credentials({
id: 'passkey',
name: 'Passkey',
credentials: {
antwort: { label: 'Antwort des Authenticators', type: 'text' },
},
async authorize(daten, anfrage) {
const ip = ipAusAnfrage(anfrage)
let antwort: AuthenticationResponseJSON
try {
antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON
} catch {
throw new PasskeyAnmeldeFehler('ungueltig')
}
let geprueft
try {
geprueft = await anmeldungPruefen(antwort)
} catch (fehler) {
// Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist -
// sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei.
await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
userEmail: '(passkey)',
bezeichnung:
fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen',
})
throw new PasskeyAnmeldeFehler(
fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig',
)
}
const { benutzer, passkey } = geprueft
// Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie
// wird erst hier geprueft, weil vorher niemand weiss, wer sich
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
const sperre = await anmeldungErlaubt(benutzer.email, ip)
if (sperre.gesperrt) {
await sperreVermerken(benutzer.email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
}
if (!benutzer.aktiv || benutzer.geloeschtAm) {
await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv')
throw new AnmeldeFehler('konto-inaktiv')
}
await Promise.all([
versuchFesthalten(benutzer.email, ip, true, 'passkey'),
fehlversucheZuruecksetzen(benutzer.email),
db.user.update({
where: { id: benutzer.id },
data: { letzteAnmeldung: new Date() },
}),
protokollieren({
aktion: 'ANMELDUNG',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
bezeichnung: `Mit Passkey „${passkey.name}“`,
}),
])
return {
id: benutzer.id,
email: benutzer.email,
name: benutzer.name,
rolle: benutzer.rolle,
sitzungsGeneration: benutzer.sitzungsGeneration,
// Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne
// eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das
// Flag ist hier also immer wahr - es wird trotzdem aus dem
// Benutzer gelesen und nicht fest gesetzt.
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
}
},
}),
], ],
callbacks: { callbacks: {
+15 -2
View File
@@ -56,8 +56,21 @@ export function AppHuelle({
Zum Inhalt springen Zum Inhalt springen
</a> </a>
{/* --- Seitenleiste am Schreibtisch --------------------------------- */} {/* --- Seitenleiste am Schreibtisch ---------------------------------
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:flex lg:flex-col">
sticky + h-dvh, nicht einfach mitwachsen lassen: Ohne feste Hoehe
wird die Leiste so hoch wie die laengste Seite. Der Fuss mit dem
Benutzermenue - und damit der einzige Weg zu "Mein Konto",
Passwort, zweitem Faktor und Passkeys - landet dann am Ende der
Seite statt am unteren Bildschirmrand. Auf /heute waren das 1632
Pixel bei 900 Pixel Fensterhoehe: unerreichbar, ohne dass etwas
kaputt aussah.
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert.
Die Navigation dazwischen hat bereits overflow-y-auto und scrollt
bei Bedarf in sich selbst. */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:sticky lg:top-0 lg:flex lg:h-dvh lg:flex-col lg:self-start">
<div className="flex h-16 items-center border-b px-5"> <div className="flex h-16 items-center border-b px-5">
<Link href="/heute" className="flex items-center gap-2 font-semibold"> <Link href="/heute" className="flex items-center gap-2 font-semibold">
<span <span
+28 -2
View File
@@ -1,5 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import { ReceiptText } from 'lucide-react' import { Repeat, ReceiptText } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf' import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelZeile } from '@/components/dashboard/kachel' import { Kachel, KachelZeile } from '@/components/dashboard/kachel'
@@ -17,7 +17,10 @@ import { formatCent, formatDatum } from '@/lib/utils'
export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) { export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
if (!daten) return null if (!daten) return null
const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt const offen =
daten.belegeOhneBuchungGesamt +
daten.buchungenOhneBelegGesamt +
daten.wiederkehrendOhneVertrag
return ( return (
<Kachel <Kachel
@@ -72,6 +75,29 @@ export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
</section> </section>
)} )}
{daten.wiederkehrendOhneVertrag > 0 && (
<section aria-labelledby="wiederkehrend-ohne-vertrag">
<h3
id="wiederkehrend-ohne-vertrag"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Regelmäßige Abbuchungen ohne Vertrag ({daten.wiederkehrendOhneVertrag})
</h3>
<KachelZeile href="/finanzen/wiederkehrend">
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 font-medium">
<Repeat className="size-4 shrink-0" aria-hidden="true" />
{daten.wiederkehrendOhneVertrag} Empfänger ansehen
</span>
<span className="block text-sm text-muted-foreground">
Wer regelmäßig abbucht, hat einen Vertrag — und der fehlt dann im
Vermögensverzeichnis.
</span>
</span>
</KachelZeile>
</section>
)}
{daten.buchungenOhneBelegGesamt > 0 && ( {daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg"> <section aria-labelledby="buchungen-ohne-beleg">
<h3 <h3
@@ -17,6 +17,21 @@ import {
* *
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle * Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
* scrollt dabei im eigenen Rahmen, nie die ganze Seite. * scrollt dabei im eigenen Rahmen, nie die ganze Seite.
*
* ACHTUNG, jeder Inhalt steht ZWEIMAL im DOM: einmal als Karte, einmal
* als Tabellenzeile. Eine der beiden Fassungen ist per Breakpoint
* versteckt, steht aber trotzdem im Baum.
*
* Fuer Tests heisst das: getByText(...).first() trifft womoeglich die
* unsichtbare Kopie, und die Pruefung scheitert mit "element is not
* visible" an einer Stelle, die augenscheinlich in Ordnung ist. Der Weg,
* der traegt, steht in e2e/hilfen.ts unter "Doppelt gerenderte Listen".
*
* Warum nicht eine Fassung fuer beide? Eine Tabelle, die sich zu Karten
* umbricht, verlangt display:block auf Zeilen und Zellen. Damit ist es
* keine Tabelle mehr - Vorlesesoftware verliert den Zusammenhang von
* Spaltenkopf und Wert, und genau der macht eine Dokumentenliste mit
* acht Spalten erst benutzbar. Die Verdopplung ist der Preis dafuer.
*/ */
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) { export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
return ( return (
@@ -0,0 +1,374 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
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 { Feld } from '@/components/feld'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
vorlageLoeschen,
vorlageSpeichern,
type VorlagenZustand,
} from '@/server/actions/vorlagen'
import { PLATZHALTER, benutztePlatzhalter } from '@/lib/schreiben/platzhalter'
export type VorlagenZeile = {
id: string
name: string
betreff: string
text: string
}
export type MitgelieferteVorlage = {
schluessel: string
name: string
beschreibung: string
betreff: string
text: string
}
/**
* Verwaltung der eigenen Schreibvorlagen.
*
* Zwei Listen, ein Editor. Die mitgelieferten Vorlagen sind Code und
* unveränderlich — von ihnen führt nur ein Weg hierher: „Als Kopie
* übernehmen". Damit kann nichts kaputtgehen, was die Anwendung braucht,
* und „wiederherstellen" ist schlicht: noch einmal kopieren.
*/
export function VorlagenVerwaltung({
betreuteId,
eigene,
mitgelieferte,
}: {
betreuteId: string
eigene: VorlagenZeile[]
mitgelieferte: MitgelieferteVorlage[]
}) {
const [offen, setOffen] = React.useState(false)
const [bearbeitet, setBearbeitet] = React.useState<VorlagenZeile | null>(null)
const [name, setName] = React.useState('')
const [betreff, setBetreff] = React.useState('')
const [text, setText] = React.useState('')
function neuBeginnen(vorlage?: Partial<VorlagenZeile>) {
setBearbeitet(null)
setName(vorlage?.name ?? '')
setBetreff(vorlage?.betreff ?? '')
setText(vorlage?.text ?? '')
setOffen(true)
}
function bearbeiten(vorlage: VorlagenZeile) {
setBearbeitet(vorlage)
setName(vorlage.name)
setBetreff(vorlage.betreff)
setText(vorlage.text)
setOffen(true)
}
return (
<div className="space-y-6">
{/* --- Eigene Vorlagen ---------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Eigene Vorlagen</CardTitle>
<CardDescription>
Formulierungen, die sich bewährt haben — einmal hinterlegt, beim Schreiben wählbar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{eigene.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine eigene Vorlage. Fang mit einer Kopie einer mitgelieferten an — oder mit
einem leeren Blatt.
</p>
) : (
<ul className="divide-y rounded-md border">
{eigene.map((vorlage) => (
<li key={vorlage.id} className="flex flex-wrap items-center gap-2 p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">{vorlage.name}</p>
<p className="text-xs text-muted-foreground">
{benutztePlatzhalter(vorlage.text).length} Platzhalter
{vorlage.betreff ? ` · Betreff: ${vorlage.betreff}` : ''}
</p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => bearbeiten(vorlage)}>
<Pencil aria-hidden="true" />
Bearbeiten
</Button>
<VorlageLoeschKnopf betreuteId={betreuteId} vorlage={vorlage} />
</li>
))}
</ul>
)}
<Button type="button" variant="outline" onClick={() => neuBeginnen()}>
<Plus aria-hidden="true" />
Neue Vorlage
</Button>
</CardContent>
</Card>
{/* --- Mitgelieferte ------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Mitgelieferte Vorlagen</CardTitle>
<CardDescription>
Unveränderlich — sie sind Teil der Anwendung und lassen sich nicht kaputtmachen. Als
Grundlage für eigene Fassungen: kopieren und anpassen.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y rounded-md border">
{mitgelieferte.map((vorlage) => (
<li key={vorlage.schluessel} className="flex flex-wrap items-center gap-2 p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">{vorlage.name}</p>
<p className="text-xs text-muted-foreground">{vorlage.beschreibung}</p>
</div>
<Badge variant="secondary">mitgeliefert</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
neuBeginnen({
name: `${vorlage.name} (eigene Fassung)`,
betreff: vorlage.betreff,
text: vorlage.text,
})
}
>
<Copy aria-hidden="true" />
Als Kopie übernehmen
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
{/* --- Editor --------------------------------------------------------- */}
{offen && (
<VorlagenEditor
betreuteId={betreuteId}
vorlageId={bearbeitet?.id ?? ''}
name={name}
betreff={betreff}
text={text}
onName={setName}
onBetreff={setBetreff}
onText={setText}
onSchliessen={() => setOffen(false)}
/>
)}
</div>
)
}
function VorlageLoeschKnopf({
betreuteId,
vorlage,
}: {
betreuteId: string
vorlage: VorlagenZeile
}) {
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageLoeschen, {})
const [gefragt, setGefragt] = React.useState(false)
if (!gefragt) {
return (
<>
{zustand.fehler && <span className="text-xs text-destructive">{zustand.fehler}</span>}
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(true)}>
<Trash2 aria-hidden="true" />
Löschen
</Button>
</>
)
}
return (
<form action={absenden} className="flex items-center gap-2">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vorlageId" value={vorlage.id} />
<Absendeknopf variant="destructive" size="sm" wartetext="…">
In den Papierkorb
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
Abbrechen
</Button>
</form>
)
}
/**
* Der Editor mit der Platzhalterliste daneben.
*
* Die Warnung zum Bestellungsabsatz kommt vom Server (zweistufig): Der
* erste Speicherversuch ohne ihn liefert die Warnung zurück, erst die
* Bestätigung speichert wirklich. So bleibt die Prüfung an EINER Stelle —
* eine zweite Prüfung im Browser könnte von der Serverfassung abweichen.
*/
function VorlagenEditor({
betreuteId,
vorlageId,
name,
betreff,
text,
onName,
onBetreff,
onText,
onSchliessen,
}: {
betreuteId: string
vorlageId: string
name: string
betreff: string
text: string
onName: (wert: string) => void
onBetreff: (wert: string) => void
onText: (wert: string) => void
onSchliessen: () => void
}) {
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageSpeichern, {})
const [trotzdem, setTrotzdem] = React.useState(false)
const textfeld = React.useRef<HTMLTextAreaElement>(null)
function platzhalterEinfuegen(platzhalterName: string) {
const feld = textfeld.current
const einschub = `{{${platzhalterName}}}`
if (!feld) {
onText(`${text}${einschub}`)
return
}
const start = feld.selectionStart ?? text.length
const ende = feld.selectionEnd ?? text.length
onText(`${text.slice(0, start)}${einschub}${text.slice(ende)}`)
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{vorlageId ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
</CardTitle>
</CardHeader>
<CardContent>
<form action={absenden} className="grid gap-6 lg:grid-cols-[1fr_260px]">
<div className="space-y-4">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vorlageId" value={vorlageId} />
<input type="hidden" name="ohneBestellungsabsatz" value={trotzdem ? 'ja' : ''} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.warnung && (
<Alert variant="warnung">
<AlertTriangle aria-hidden="true" />
<AlertDescription>
<p>{zustand.warnung}</p>
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={trotzdem}
onChange={(ereignis) => setTrotzdem(ereignis.target.checked)}
/>
Bewusst ohne Bestellungsabsatz speichern
</label>
</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<Feld id="name" beschriftung="Name der Vorlage" fehler={zustand.felder?.name} pflicht>
<Input
name="name"
value={name}
onChange={(ereignis) => onName(ereignis.target.value)}
maxLength={120}
placeholder="z. B. Auskunftsersuchen Krankenkasse"
/>
</Feld>
<Feld
id="betreff"
beschriftung="Betreff"
erklaerung="Darf Platzhalter enthalten. Wird beim Erzeugen zum Betreff des Schreibens."
>
<Input
name="betreff"
value={betreff}
onChange={(ereignis) => onBetreff(ereignis.target.value)}
maxLength={200}
/>
</Feld>
<Feld id="text" beschriftung="Text der Vorlage" fehler={zustand.felder?.text} pflicht>
<Textarea
ref={textfeld}
name="text"
value={text}
onChange={(ereignis) => onText(ereignis.target.value)}
rows={18}
className="font-mono text-sm leading-relaxed"
/>
</Feld>
<div className="flex gap-2">
<Absendeknopf wartetext="Wird gespeichert …">Speichern</Absendeknopf>
<Button type="button" variant="ghost" onClick={onSchliessen}>
Schließen
</Button>
</div>
</div>
{/* Die Platzhalterliste - niemand soll Namen raten muessen. */}
<aside className="space-y-2 rounded-md border p-3">
<p className="text-sm font-medium">Platzhalter</p>
<p className="text-xs text-muted-foreground">
Klick fügt an der Schreibmarke ein. Fehlt beim Erzeugen der Wert, bleibt eine
sichtbare Lücke — nichts verschwindet still.
</p>
<ul className="space-y-1">
{PLATZHALTER.map((platzhalter) => (
<li key={platzhalter.name}>
<button
type="button"
onClick={() => platzhalterEinfuegen(platzhalter.name)}
className="w-full rounded px-2 py-1 text-left text-xs hover:bg-accent"
title={platzhalter.quelle}
>
<code className="text-primary">{'{{'}{platzhalter.name}{'}}'}</code>
<span className="block text-muted-foreground">
{platzhalter.beschriftung}
</span>
</button>
</li>
))}
</ul>
</aside>
</form>
</CardContent>
</Card>
)
}
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import Link from 'next/link' 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 { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils' import { cn, formatCent } from '@/lib/utils'
@@ -104,10 +104,19 @@ export function Belegzeichen({
belegTitel, belegTitel,
begruendung, begruendung,
belegId, belegId,
vertragBezeichnung = null,
vertragAutomatisch = false,
betragZuPruefen = false,
}: { }: {
belegId: string | null belegId: string | null
belegTitel: string | null belegTitel: string | null
begruendung: 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) { if (belegId) {
return ( 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() !== '') { if (begruendung && begruendung.trim() !== '') {
return ( return (
<Badge variant="gelb" title={begruendung}> <Badge variant="gelb" title={begruendung}>
+34 -1
View File
@@ -33,6 +33,12 @@ export type BelegAuswahl = {
titel: string titel: string
} }
export type VertragAuswahl = {
id: string
bezeichnung: string
gesellschaft: string | null
}
export type BuchungWerte = { export type BuchungWerte = {
id: string id: string
kontoId: string | null kontoId: string | null
@@ -45,6 +51,7 @@ export type BuchungWerte = {
verwendungszweck: string | null verwendungszweck: string | null
belegId: string | null belegId: string | null
belegFehltBegruendung: string | null belegFehltBegruendung: string | null
vertragId: string | null
betrifftBetreute: boolean betrifftBetreute: boolean
vermoegenswirksam: boolean vermoegenswirksam: boolean
} }
@@ -65,6 +72,7 @@ export function Buchungsformular({
betreuteId, betreuteId,
konten, konten,
belege, belege,
vertraege,
buchung, buchung,
standardDatum, standardDatum,
schnellerfassung = false, schnellerfassung = false,
@@ -72,6 +80,7 @@ export function Buchungsformular({
betreuteId: string betreuteId: string
konten: KontoAuswahl[] konten: KontoAuswahl[]
belege: BelegAuswahl[] belege: BelegAuswahl[]
vertraege: VertragAuswahl[]
buchung?: BuchungWerte buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */ /** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string standardDatum?: string
@@ -87,6 +96,7 @@ export function Buchungsformular({
) )
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true) const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '') const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const [vertragId, setVertragId] = React.useState(buchung?.vertragId ?? '')
const konto = konten.find((k) => k.id === kontoId) const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
@@ -275,7 +285,30 @@ export function Buchungsformular({
</Select> </Select>
</Feld> </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 <Feld
id="belegFehltBegruendung" id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?" beschriftung="Warum fehlt der Beleg?"
@@ -0,0 +1,177 @@
'use client'
import * as React from 'react'
import { useActionState } from '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,
} from '@/server/actions/finanzen'
/**
* „Kein Vertrag" — eine Gruppe dauerhaft aus der Liste nehmen.
*
* Die Begründung ist freiwillig, steht aber im Formular ganz vorn. In einem
* Jahr weiß niemand mehr, warum „Stadtwerke Bamberg" abgehakt wurde, und
* dann fängt die Prüfung von vorn an. Ein halber Satz jetzt spart das.
*/
export function AlsGeklaertAbhaken({
betreuteId,
schluessel,
anzeigename,
}: {
betreuteId: string
schluessel: string
anzeigename: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeAbhaken,
{},
)
const [offen, setOffen] = React.useState(false)
if (!offen) {
return (
<div className="space-y-2">
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(true)}>
<XCircle aria-hidden="true" />
Kein Vertrag
</Button>
</div>
)
}
return (
<form action={absenden} className="space-y-2 rounded-md border p-3">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="schluessel" value={schluessel} />
<input type="hidden" name="anzeigename" value={anzeigename} />
<p className="text-sm">
„{anzeigename}“ verschwindet aus der Liste. Zurückholen geht jederzeit.
</p>
<Input
name="begruendung"
placeholder="Warum kein Vertrag? (z. B. Miete, Strom, Steuer)"
maxLength={200}
autoFocus
/>
<div className="flex gap-2">
<Absendeknopf size="sm" wartetext="Wird abgehakt …">
Abhaken
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
/** Holt eine abgehakte Gruppe zurück in die Liste. */
export function Zurueckholen({
betreuteId,
eintragId,
}: {
betreuteId: string
eintragId: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeZurueckholen,
{},
)
return (
<form action={absenden} className="inline">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="eintragId" value={eintragId} />
{zustand.fehler && (
<span className="mr-2 text-xs text-destructive">{zustand.fehler}</span>
)}
<Absendeknopf variant="ghost" size="sm" wartetext="…">
<Undo2 aria-hidden="true" />
Zurückholen
</Absendeknopf>
</form>
)
}
/**
* Zeigt die Rückmeldung der letzten Aktion.
*
* Bewusst getrennt von den Knöpfen: Nach dem Abhaken verschwindet die
* ganze Zeile samt Knopf, und mit ihr die Erfolgsmeldung. Sie hier
* anzuzeigen wäre sinnlos — deshalb steht der Erfolg als kurzer Hinweis
* oben auf der Seite, sobald die Liste neu geladen ist.
*/
export function Rueckmeldung({ text }: { text: string | null }) {
if (!text) return null
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{text}</AlertDescription>
</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>
)
}
+22 -1
View File
@@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' import {
KONTAKT_ANREDEN,
KONTAKT_ROLLEN,
KONTAKT_ROLLE_BESCHRIFTUNG,
} from '@/lib/validierung/kontakte'
import { ANREDE_BESCHRIFTUNG } from '@/lib/kontakte/anrede'
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte' import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/** /**
@@ -24,6 +29,7 @@ export type KontaktWerte = {
name: string name: string
organisation: string | null organisation: string | null
rolle: string rolle: string
anrede: string
telefon: string | null telefon: string | null
mobil: string | null mobil: string | null
fax: string | null fax: string | null
@@ -91,6 +97,21 @@ export function KontaktFormular({
</Select> </Select>
</Feld> </Feld>
<Feld
id="anrede"
beschriftung="Anrede"
erklaerung="Für die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet — geraten wird nie."
fehler={zustand.felder?.anrede}
>
<Select name="anrede" defaultValue={wert('anrede') || 'OHNE'}>
{KONTAKT_ANREDEN.map((anrede) => (
<option key={anrede} value={anrede}>
{ANREDE_BESCHRIFTUNG[anrede]}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen dort" beschriftung="Aktenzeichen dort"
+277
View File
@@ -0,0 +1,277 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
AlertCircle,
CheckCircle2,
Fingerprint,
Loader2,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
import {
passkeyEinrichtungAbschliessen,
passkeyEinrichtungStarten,
passkeyEntfernen,
} from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startRegistration,
} from '@/lib/passkey-browser'
import type { PasskeyUebersicht } from '@/lib/auth/passkey'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
/**
* Verwaltung der eigenen Passkeys unter "Mein Konto".
*
* Die Liste kommt vom Server; nach jeder Aenderung wird die Seite neu
* geladen (router.refresh) statt den Zustand hier mitzufuehren. Bei einer
* Handvoll Eintraegen ist das die ehrlichere Loesung: Was auf dem Bildschirm
* steht, steht auch so in der Datenbank.
*/
export function PasskeyVerwaltung({
passkeys,
zweiterFaktorAktiv,
moeglich,
}: {
passkeys: PasskeyUebersicht[]
zweiterFaktorAktiv: boolean
/** Ob der Server Passkeys ueberhaupt anbieten kann (https). */
moeglich: boolean
}) {
const router = useRouter()
const [browserKann, setBrowserKann] = useState(false)
const [name, setName] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [entfernt, setEntfernt] = useState<string | null>(null)
const [fehler, setFehler] = useState<string | null>(null)
const [erfolg, setErfolg] = useState<string | null>(null)
const [nachfrage, setNachfrage] = useState<string | null>(null)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function einrichten() {
setFehler(null)
setErfolg(null)
if (name.trim() === '') {
setFehler('Bitte gib dem Gerät zuerst einen Namen.')
return
}
setLaeuft(true)
try {
const start = await passkeyEinrichtungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startRegistration({ optionsJSON: start.optionen })
const ergebnis = await passkeyEinrichtungAbschliessen(name, JSON.stringify(antwort))
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setName('')
setErfolg(ergebnis.meldung ?? 'Passkey eingerichtet.')
router.refresh()
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme, true))
} finally {
setLaeuft(false)
}
}
async function entfernen(id: string) {
setFehler(null)
setErfolg(null)
setNachfrage(null)
setEntfernt(id)
try {
const ergebnis = await passkeyEntfernen(id)
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setErfolg(ergebnis.meldung ?? 'Passkey entfernt.')
router.refresh()
} finally {
setEntfernt(null)
}
}
if (!moeglich) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation ist nicht über
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">https</code>
erreichbar — sobald sie es ist, erscheint hier die Verwaltung.
</AlertDescription>
</Alert>
)
}
if (!zweiterFaktorAktiv) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ginge das
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die Akte. Der Passkey
ist die bequeme Tür, nicht die einzige.
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-4">
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
{erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{erfolg}</AlertDescription>
</Alert>
)}
{passkeys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an
— das bleibt auch danach möglich.
</p>
) : (
<ul className="divide-y rounded-md border">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-start gap-3 p-3">
<Smartphone
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="font-medium">{passkey.name}</p>
<p className="text-xs text-muted-foreground">
Eingerichtet am {formatDatum(passkey.erstelltAm)}
{passkey.zuletztBenutztAm
? ` · zuletzt benutzt ${formatDatumZeit(passkey.zuletztBenutztAm)}`
: ' · noch nicht benutzt'}
</p>
{passkey.geraeteArt === 'singleDevice' && (
<p className="mt-1 text-xs text-muted-foreground">
Liegt nur auf diesem Gerät. Geht es verloren, ist auch der Passkey weg.
</p>
)}
</div>
{passkey.gesichert && (
<Badge variant="secondary" className="shrink-0">
gesichert
</Badge>
)}
{nachfrage === passkey.id ? (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void entfernen(passkey.id)}
disabled={entfernt === passkey.id}
>
{entfernt === passkey.id ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : null}
Wirklich entfernen
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setNachfrage(null)}
>
Abbrechen
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setNachfrage(passkey.id)}
aria-label={`Passkey „${passkey.name}“ entfernen`}
>
<Trash2 aria-hidden="true" />
Entfernen
</Button>
)}
</li>
))}
</ul>
)}
{browserKann ? (
<div className="space-y-3 rounded-md border border-dashed p-3">
<Feld
id="passkey-name"
beschriftung="Neues Gerät"
erklaerung="Ein Name, an dem du es später wiedererkennst — etwa „iPhone“ oder „Laptop Büro“."
>
<Input
id="passkey-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
maxLength={60}
placeholder="iPhone"
autoComplete="off"
/>
</Feld>
<Button type="button" onClick={() => void einrichten()} disabled={laeuft}>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Plus aria-hidden="true" />
Passkey einrichten
</>
)}
</Button>
</div>
) : (
<Alert variant="hinweis">
<Fingerprint aria-hidden="true" />
<AlertDescription>
Dieser Browser kann keine Passkeys. Auf einem anderen Gerät klappt es vermutlich — die
bereits eingerichteten bleiben davon unberührt.
</AlertDescription>
</Alert>
)}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertCircle, Fingerprint, Loader2 } from 'lucide-react'
import { passkeyAnmeldungAbschliessen, passkeyAnmeldungStarten } from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startAuthentication,
} from '@/lib/passkey-browser'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
/**
* Knopf "Mit Passkey anmelden" auf der Anmeldeseite.
*
* Der Ablauf ist dreiteilig: Optionen vom Server holen, das Geraet fragen,
* die Antwort zurueckschicken. Gelingt der letzte Schritt, leitet die Server
* Action weiter - dieser Baustein sieht das Ergebnis dann gar nicht mehr.
*
* Sichtbar ist der Knopf nur, wenn der Server ihn anbietet (https) UND der
* Browser WebAuthn kann. Die zweite Pruefung laeuft erst nach dem ersten
* Rendern: Auf dem Server gibt es kein navigator-Objekt, und ein Knopf, der
* beim Laden verschwindet, ist unangenehmer als einer, der kurz spaeter
* erscheint.
*/
export function PasskeyAnmeldung({ weiter }: { weiter: string }) {
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [browserKann, setBrowserKann] = useState(false)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function anmelden() {
setFehler(null)
setLaeuft(true)
try {
const start = await passkeyAnmeldungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startAuthentication({
optionsJSON: start.optionen,
})
const ergebnis = await passkeyAnmeldungAbschliessen(JSON.stringify(antwort), weiter)
// Bei Erfolg wird gar nichts zurueckgegeben - die Action leitet weiter.
if (!ergebnis.ok) setFehler(ergebnis.fehler)
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme))
} finally {
setLaeuft(false)
}
}
if (!browserKann) return null
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
<span className="h-px flex-1 bg-border" />
</div>
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={anmelden}
disabled={laeuft}
>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Fingerprint aria-hidden="true" />
Mit Passkey anmelden
</>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail und Passwort.
</p>
</div>
)
}
@@ -0,0 +1,521 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import Link from 'next/link'
import {
AlertCircle,
CheckCircle2,
Copy,
FileDown,
FolderInput,
Info,
Mail,
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'
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
export type KontaktAuswahl = {
id: string
name: string
organisation: string | null
strasse: string | null
plz: string | null
ort: string | null
email: 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
empfaengerAnschrift: 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(
entwurf.empfaengerAnschrift,
)
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false)
const [mailKopiert, setMailKopiert] = React.useState(false)
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 ?? ''} />
<input type="hidden" name="versandweg" value={versandweg} />
<input type="hidden" name="empfaengerEmail" value={empfaengerEmail} />
</>
)
}
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'),
)
setEmpfaengerEmail(kontakt.email ?? '')
}
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)
/**
* Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf.
*
* Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet
* hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf.
*/
function EmailAusgabe() {
const fassung = emailFassung(text, betreff)
const adresseGueltig = siehtAusWieEmail(empfaengerEmail)
const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null
async function kopieren(inhalt: string, merker: (wert: boolean) => void) {
try {
await navigator.clipboard.writeText(inhalt)
merker(true)
window.setTimeout(() => merker(false), 2500)
} catch {
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
}
}
return (
<div className="space-y-4 rounded-md border p-4">
<Feld
id="emailBetreff"
beschriftung="Betreff der E-Mail"
erklaerung="Aus der Betreffzeile des Briefes übernommen."
>
<div className="flex gap-2">
<Input id="emailBetreff" value={fassung.betreff} readOnly className="flex-1" />
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.betreff, setBetreffKopiert)}
>
<Copy aria-hidden="true" />
{betreffKopiert ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
</Feld>
<Feld
id="emailText"
beschriftung="Text der E-Mail"
erklaerung="Ohne Absenderblock, Anschrift und Datumszeile — die gehören in den Brief, nicht in die Mail. Der Nachweis der Bestellung bleibt drin."
>
<div className="space-y-2">
<Textarea
id="emailText"
value={fassung.text}
readOnly
rows={14}
className="font-mono text-sm leading-relaxed"
/>
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.text, setMailKopiert)}
>
<Copy aria-hidden="true" />
{mailKopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
</div>
</Feld>
<div className="flex flex-wrap items-center gap-2">
{mailto && (
<Button asChild variant="outline">
<a href={mailto}>
<Mail aria-hidden="true" />
Im Mailprogramm öffnen
</a>
</Button>
)}
{adresseGueltig && (
<form action="/schreiben/eml" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als E-Mail-Entwurf (.eml)
</Button>
</form>
)}
</div>
{!adresseGueltig && (
<p className="text-sm text-muted-foreground">
Trag oben eine E-Mail-Adresse ein, dann erscheinen hier die Knöpfe fürs
Mailprogramm. Kopieren geht auch ohne.
</p>
)}
{adresseGueltig && !mailto && (
<p className="text-sm text-muted-foreground">
Der Text ist zu lang für einen mailto-Link — manche Mailprogramme würden ihn
stillschweigend abschneiden. Nimm die .eml-Datei, sie trägt den vollen Text.
</p>
)}
</div>
)
}
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>
<Feld
id="empfaengerEmail"
beschriftung="E-Mail-Adresse"
erklaerung="Nur nötig, wenn das Schreiben als E-Mail hinausgehen soll. Wird aus dem gewählten Kontakt übernommen."
>
<Input
id="empfaengerEmail"
type="email"
value={empfaengerEmail}
onChange={(ereignis) => setEmpfaengerEmail(ereignis.target.value)}
maxLength={200}
placeholder="service@versicherung.de"
/>
</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>
<CardDescription>
Wie geht es hinaus? Die Wahl bestimmt auch, wie das Schreiben in der Akte
gekennzeichnet wird.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex gap-2" role="group" aria-label="Versandweg">
<Button
type="button"
variant={versandweg === 'BRIEF' ? 'default' : 'outline'}
onClick={() => setVersandweg('BRIEF')}
>
<FileDown aria-hidden="true" />
Als Brief
</Button>
<Button
type="button"
variant={versandweg === 'EMAIL' ? 'default' : 'outline'}
onClick={() => setVersandweg('EMAIL')}
>
<Mail aria-hidden="true" />
Als E-Mail
</Button>
</div>
{versandweg === 'EMAIL' && <EmailAusgabe />}
<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>
{versandweg === 'BRIEF' && (
<>
{/* 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>
)
}
@@ -0,0 +1,57 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { FileText } from 'lucide-react'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
/**
* Vorlagenauswahl über dem Schreiben-Editor.
*
* Die Wahl lädt die Seite mit ?vorlageId neu — der Entwurf entsteht
* serverseitig, wo die Platzhalter aufgelöst werden. Ein Wechsel wirft
* damit auch bewusst den bearbeiteten Text weg; deshalb steht die Auswahl
* VOR dem Editor, nicht darin.
*/
export function VorlagenWahl({
vorlagen,
aktiveVorlageId,
}: {
vorlagen: { id: string; name: string }[]
aktiveVorlageId: string | null
}) {
const router = useRouter()
const parameter = useSearchParams()
function wechseln(vorlageId: string) {
const neu = new URLSearchParams(parameter.toString())
if (vorlageId) neu.set('vorlageId', vorlageId)
else neu.delete('vorlageId')
router.push(`/schreiben/neu?${neu.toString()}`)
}
return (
<div className="flex items-start gap-2.5 rounded-md border bg-muted/30 p-3">
<FileText className="mt-8 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Feld
id="vorlageWahl"
beschriftung="Aus eigener Vorlage"
erklaerung="Ersetzt den Entwurf unten — was dort schon steht, geht dabei verloren."
className="flex-1"
>
<Select
id="vorlageWahl"
value={aktiveVorlageId ?? ''}
onChange={(ereignis) => wechseln(ereignis.target.value)}
>
<option value="">– leerer Entwurf –</option>
{vorlagen.map((vorlage) => (
<option key={vorlage.id} value={vorlage.id}>
{vorlage.name}
</option>
))}
</Select>
</Feld>
</div>
)
}
+310 -310
View File
@@ -1,310 +1,310 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine' import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten' import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe' import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = { export type HeimkostenWerte = {
jahr: number jahr: number
monat: number monat: number
pflegeEigenanteil: string pflegeEigenanteil: string
unterkunftVerpflegung: string unterkunftVerpflegung: string
investitionskosten: string investitionskosten: string
ausbildungsumlage: string ausbildungsumlage: string
zusatzleistungen: string zusatzleistungen: string
pflegekasse: string pflegekasse: string
leistungszuschlagProzent: number leistungszuschlagProzent: number
leistungszuschlag: string leistungszuschlag: string
rente: string rente: string
sonstigeEinkuenfte: string sonstigeEinkuenfte: string
sozialhilfe: string sozialhilfe: string
eigenmittel: string eigenmittel: string
barbetrag: string barbetrag: string
bekleidungspauschale: string bekleidungspauschale: string
abwesenheitstage: number abwesenheitstage: number
bemerkung: string bemerkung: string
} }
/** /**
* Ein Monat der Gegenüberstellung. * Ein Monat der Gegenüberstellung.
* *
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der * Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person * Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der * verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht. * Stelle, an der es auf der Rechnung steht.
*/ */
export function HeimkostenFormular({ export function HeimkostenFormular({
werte, werte,
jahre, jahre,
hinweisPflegekasse, hinweisPflegekasse,
vorschlagBarbetrag, vorschlagBarbetrag,
}: { }: {
werte: HeimkostenWerte werte: HeimkostenWerte
jahre: number[] jahre: number[]
/** /**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN * Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der * Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert * Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts. * deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/ */
hinweisPflegekasse: string hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string } vorschlagBarbetrag: { betrag: string; text: string }
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag) const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return ( return (
<form action={aktion} className="space-y-6"> <form action={aktion} className="space-y-6">
<Meldung zustand={zustand} /> <Meldung zustand={zustand} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Monat erfassen</CardTitle> <CardTitle>Monat erfassen</CardTitle>
<CardDescription> <CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben. überschrieben.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}> <Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}> <Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => ( {jahre.map((j) => (
<option key={j} value={j}> <option key={j} value={j}>
{j} {j}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}> <Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}> <Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => ( {MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}> <option key={name} value={i + 1}>
{name} {name}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</div> </div>
<Feldgruppe <Feldgruppe
titel="Kosten" titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen." beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
> >
<Feld <Feld
id="pflegeEigenanteil" id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil" beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil} fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag." erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
> >
<Input <Input
name="pflegeEigenanteil" name="pflegeEigenanteil"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.pflegeEigenanteil} defaultValue={werte.pflegeEigenanteil}
/> />
</Feld> </Feld>
<Feld <Feld
id="unterkunftVerpflegung" id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung" beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung} fehler={zustand.felder?.unterkunftVerpflegung}
> >
<Input <Input
name="unterkunftVerpflegung" name="unterkunftVerpflegung"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung} defaultValue={werte.unterkunftVerpflegung}
/> />
</Feld> </Feld>
<Feld <Feld
id="investitionskosten" id="investitionskosten"
beschriftung="Investitionskosten" beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten} fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter." erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
> >
<Input <Input
name="investitionskosten" name="investitionskosten"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.investitionskosten} defaultValue={werte.investitionskosten}
/> />
</Feld> </Feld>
<Feld <Feld
id="ausbildungsumlage" id="ausbildungsumlage"
beschriftung="Ausbildungsumlage" beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage} fehler={zustand.felder?.ausbildungsumlage}
> >
<Input <Input
name="ausbildungsumlage" name="ausbildungsumlage"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.ausbildungsumlage} defaultValue={werte.ausbildungsumlage}
/> />
</Feld> </Feld>
<Feld <Feld
id="zusatzleistungen" id="zusatzleistungen"
beschriftung="Zusatzleistungen" beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen} fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab." erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
> >
<Input <Input
name="zusatzleistungen" name="zusatzleistungen"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.zusatzleistungen} defaultValue={werte.zusatzleistungen}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?"> <Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld <Feld
id="pflegekasse" id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)" beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse} fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse} erklaerung={hinweisPflegekasse}
> >
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} /> <Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlagProzent" id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent" beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent} fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung." erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
> >
<Input <Input
name="leistungszuschlagProzent" name="leistungszuschlagProzent"
type="number" type="number"
min={0} min={0}
max={100} max={100}
defaultValue={werte.leistungszuschlagProzent} defaultValue={werte.leistungszuschlagProzent}
/> />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlag" id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro" beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag} fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung." erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
> >
<Input <Input
name="leistungszuschlag" name="leistungszuschlag"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.leistungszuschlag} defaultValue={werte.leistungszuschlag}
/> />
</Feld> </Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}> <Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} /> <Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld> </Feld>
<Feld <Feld
id="sonstigeEinkuenfte" id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte" beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte} fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen." erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
> >
<Input <Input
name="sonstigeEinkuenfte" name="sonstigeEinkuenfte"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte} defaultValue={werte.sonstigeEinkuenfte}
/> />
</Feld> </Feld>
<Feld <Feld
id="sozialhilfe" id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe" beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe} fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt." erklaerung="Was der Bezirk laut Bescheid übernimmt."
> >
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} /> <Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld> </Feld>
<Feld <Feld
id="eigenmittel" id="eigenmittel"
beschriftung="Eigenmittel" beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel} fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde." erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
> >
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} /> <Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Was verbleiben muss" titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest." beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
> >
<Feld <Feld
id="barbetrag" id="barbetrag"
beschriftung="Barbetrag" beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag} fehler={zustand.felder?.barbetrag}
erklaerung={ erklaerung={
<> <>
{vorschlagBarbetrag.text}{' '} {vorschlagBarbetrag.text}{' '}
<button <button
type="button" type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)} onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline" className="font-medium text-primary underline-offset-4 hover:underline"
> >
Wert übernehmen Wert übernehmen
</button> </button>
</> </>
} }
> >
<Input <Input
name="barbetrag" name="barbetrag"
inputMode="decimal" inputMode="decimal"
value={barbetrag} value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)} onChange={(e) => setBarbetrag(e.target.value)}
/> />
</Feld> </Feld>
<Feld <Feld
id="bekleidungspauschale" id="bekleidungspauschale"
beschriftung="Bekleidungspauschale" beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale} fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid." erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
> >
<Input <Input
name="bekleidungspauschale" name="bekleidungspauschale"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.bekleidungspauschale} defaultValue={werte.bekleidungspauschale}
/> />
</Feld> </Feld>
<Feld <Feld
id="abwesenheitstage" id="abwesenheitstage"
beschriftung="Abwesenheitstage" beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage} fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung." erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
> >
<Input <Input
name="abwesenheitstage" name="abwesenheitstage"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={werte.abwesenheitstage} defaultValue={werte.abwesenheitstage}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}> <Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} /> <Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld> </Feld>
</CardContent> </CardContent>
</Card> </Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden> <Absenden>Monat speichern</Absenden>
</form> </form>
) )
} }
@@ -1,7 +1,8 @@
'use client' 'use client'
import Link from 'next/link'
import { useActionState } from 'react' 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -10,6 +11,7 @@ import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld' import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular' import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
import { kuendigungVermerken } from '@/server/actions/vertraege' import { kuendigungVermerken } from '@/server/actions/vertraege'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
@@ -161,6 +163,15 @@ export function KuendigungsAssistent({
beschriftung="Kündigungsschreiben" beschriftung="Kündigungsschreiben"
hoehe="min-h-[36rem]" 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> </CardContent>
</Card> </Card>
+41 -4
View File
@@ -1,8 +1,10 @@
'use client' 'use client'
import Link from 'next/link'
import { useActionState, useState } from 'react' 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -35,6 +37,7 @@ export function Rueckkaufswert({
anschreiben, anschreiben,
stichtagVorschlag, stichtagVorschlag,
darfSchreiben, darfSchreiben,
alterHinweis,
}: { }: {
betreuteId: string betreuteId: string
vertragId: string vertragId: string
@@ -45,6 +48,8 @@ export function Rueckkaufswert({
anschreiben: string anschreiben: string
stichtagVorschlag: string stichtagVorschlag: string
darfSchreiben: boolean darfSchreiben: boolean
/** Kurztext, wenn der juengste Wert zu alt ist - sonst null. */
alterHinweis: string | null
}) { }) {
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>( const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
rueckkaufswertErfassen, rueckkaufswertErfassen,
@@ -54,6 +59,9 @@ export function Rueckkaufswert({
rueckkaufswertAnfordern, rueckkaufswertAnfordern,
{}, {},
) )
// Die Liste kommt bereits juengster Wert zuerst von der Seite.
const letzter = werte[0] ?? null
const [briefOffen, setBriefOffen] = useState(false) const [briefOffen, setBriefOffen] = useState(false)
const [erfassenOffen, setErfassenOffen] = useState(false) const [erfassenOffen, setErfassenOffen] = useState(false)
@@ -87,7 +95,7 @@ export function Rueckkaufswert({
</Alert> </Alert>
)} )}
{werte.length === 0 ? ( {!letzter ? (
<Leerzustand <Leerzustand
titel="Noch kein Rückkaufswert erfasst" titel="Noch kein Rückkaufswert erfasst"
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben." beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
@@ -102,7 +110,26 @@ export function Rueckkaufswert({
} }
/> />
) : ( ) : (
<Table> <>
{/* Der zuletzt mitgeteilte Wert gehoert nach oben: Wer beim
Telefonat mit der Gesellschaft danach gefragt wird, hat
keine Zeit, eine Historie zu lesen. */}
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Zuletzt mitgeteilt</p>
<p className="text-2xl font-semibold tabular-nums">
{formatCent(letzter.wertCent)}
</p>
<p className="text-sm text-muted-foreground">
zum Stichtag {formatDatum(letzter.stichtag)} · {letzter.wertquelle}
</p>
{alterHinweis && (
<Badge variant="gelb" className="mt-2">
{alterHinweis} — vor dem nächsten Bericht neu anfragen
</Badge>
)}
</div>
<Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Stichtag</TableHead> <TableHead>Stichtag</TableHead>
@@ -125,7 +152,8 @@ export function Rueckkaufswert({
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</>
)} )}
{darfSchreiben && ( {darfSchreiben && (
@@ -149,6 +177,15 @@ export function Rueckkaufswert({
hoehe="min-h-[32rem]" 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> <form action={anfrageAktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} /> <input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} /> <input type="hidden" name="vertragId" value={vertragId} />
@@ -0,0 +1,165 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { AlertCircle, FileArchive, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
export type SerienZeile = {
vertragId: string
bezeichnung: string
gesellschaft: string | null
artBeschriftung: string
moeglich: boolean
hindernis: string | null
vorausgewaehlt: boolean
}
/**
* Auswahl und Start der Serienabfrage.
*
* Die Zusammenfassung ("Es entstehen N Schreiben") steht direkt auf dem
* Knopf. Man soll den Fehlgriff bemerken, BEVOR zehn Briefe entstehen —
* nicht danach beim Aufräumen.
*
* Der Start läuft als gewöhnliches Formular-POST gegen die Route: Der
* Browser bekommt das ZIP als Download, und die Nebenwirkungen (Akte,
* Fristen, Anfragedatum) sind serverseitig schon passiert. Nach dem
* Klick wird die Seite neu geladen, damit die Sperrvermerke stimmen.
*/
export function SerienabfrageFormular({
betreuteId,
zeilen,
stichtagVorschlag,
}: {
betreuteId: string
zeilen: SerienZeile[]
stichtagVorschlag: string
}) {
const router = useRouter()
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(
() => new Set(zeilen.filter((z) => z.vorausgewaehlt).map((z) => z.vertragId)),
)
const [stichtag, setStichtag] = React.useState(stichtagVorschlag)
const [gestartet, setGestartet] = React.useState(false)
function umschalten(id: string) {
setGewaehlt((bisher) => {
const neu = new Set(bisher)
if (neu.has(id)) neu.delete(id)
else neu.add(id)
return neu
})
}
const anzahl = gewaehlt.size
function beimAbsenden() {
// Das Formular lädt als Download - die Seite bleibt stehen. Nach
// kurzer Zeit neu laden, damit Sperrvermerke und "Wartet auf andere"
// den neuen Stand zeigen.
setGestartet(true)
window.setTimeout(() => {
router.refresh()
setGestartet(false)
}, 4000)
}
return (
<form
action="/vertraege/serienabfrage"
method="post"
onSubmit={beimAbsenden}
className="space-y-5"
>
<input type="hidden" name="betreuteId" value={betreuteId} />
<Feld
id="stichtag"
beschriftung="Stichtag der Auskünfte"
erklaerung="Zu diesem Tag sollen die Gesellschaften den Rückkaufswert mitteilen — üblicherweise der Stichtag des Vermögensverzeichnisses oder der Beginn des Betreuungsjahres."
pflicht
>
<Input
id="stichtag"
name="stichtag"
type="date"
value={stichtag}
onChange={(ereignis) => setStichtag(ereignis.target.value)}
required
/>
</Feld>
<ul className="divide-y rounded-md border">
{zeilen.map((zeile) => (
<li key={zeile.vertragId} className="flex items-start gap-3 p-3">
{zeile.moeglich ? (
<Checkbox
id={`vertrag-${zeile.vertragId}`}
name="vertragId"
value={zeile.vertragId}
checked={gewaehlt.has(zeile.vertragId)}
onChange={() => umschalten(zeile.vertragId)}
className="mt-0.5"
/>
) : (
<span className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
)}
<label
htmlFor={zeile.moeglich ? `vertrag-${zeile.vertragId}` : undefined}
className={`min-w-0 flex-1 ${zeile.moeglich ? 'cursor-pointer' : 'opacity-60'}`}
>
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{zeile.bezeichnung}</span>
<Badge variant="secondary">{zeile.artBeschriftung}</Badge>
</span>
<span className="block text-sm text-muted-foreground">
{zeile.gesellschaft ?? 'ohne Gesellschaft'}
</span>
{zeile.hindernis && (
<span className="mt-0.5 block text-xs text-ampel-orange">{zeile.hindernis}</span>
)}
</label>
</li>
))}
</ul>
{zeilen.length === 0 && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
Es gibt keine Verträge, die für eine Rückkaufswert-Anfrage in Frage kommen. Lege
zuerst die Versicherungen unter <em>Verträge</em> an — oder finde sie über{' '}
<em>Vermögen &amp; Kasse → Wiederkehrende Zahlungen</em>.
</AlertDescription>
</Alert>
)}
{anzahl === 0 && zeilen.length > 0 && (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>Kein Vertrag gewählt — es entsteht nichts.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Button type="submit" disabled={anzahl === 0 || gestartet} size="lg">
<FileArchive aria-hidden="true" />
{gestartet
? 'Wird erzeugt …'
: `${anzahl} Schreiben erzeugen und herunterladen`}
</Button>
<p className="text-sm text-muted-foreground">
Je Vertrag entsteht eine Word-Datei im ZIP, eine PDF-Fassung in der Akte, eine
Wiedervorlage nach 28 Tagen — und der Vertrag gilt als angefragt.
</p>
</div>
</form>
)
}
+237 -237
View File
@@ -1,237 +1,237 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client' import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular' import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG, BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege' import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege' import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz' import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = { export type VertragWerte = {
id?: string id?: string
bezeichnung: string bezeichnung: string
gesellschaft: string gesellschaft: string
art: string art: string
status: string status: string
vertragsnummer: string vertragsnummer: string
beginnAm: string beginnAm: string
endeAm: string endeAm: string
kuendigungsfristMonate: string kuendigungsfristMonate: string
kuendigungZum: string kuendigungZum: string
gekuendigtAm: string gekuendigtAm: string
beitrag: string beitrag: string
rhythmus: string rhythmus: string
bewertungSozialhilfe: string bewertungSozialhilfe: string
bewertungBegruendung: string bewertungBegruendung: string
notizen: string notizen: string
} }
export const LEERER_VERTRAG: VertragWerte = { export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '', bezeichnung: '',
gesellschaft: '', gesellschaft: '',
art: 'LEBENSVERSICHERUNG', art: 'LEBENSVERSICHERUNG',
status: 'AKTIV', status: 'AKTIV',
vertragsnummer: '', vertragsnummer: '',
beginnAm: '', beginnAm: '',
endeAm: '', endeAm: '',
kuendigungsfristMonate: '3', kuendigungsfristMonate: '3',
kuendigungZum: '', kuendigungZum: '',
gekuendigtAm: '', gekuendigtAm: '',
beitrag: '', beitrag: '',
rhythmus: 'MONATLICH', rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT', bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '', bewertungBegruendung: '',
notizen: '', notizen: '',
} }
export function VertragFormular({ export function VertragFormular({
betreuteId, betreuteId,
werte, werte,
}: { }: {
betreuteId: string betreuteId: string
werte: VertragWerte werte: VertragWerte
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe) const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id const istNeu = !werte.id
return ( return (
<form action={aktion} className="space-y-8" noValidate> <form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} /> <input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />} {werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} /> <Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer."> <Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld <Feld
id="bezeichnung" id="bezeichnung"
beschriftung="Bezeichnung" beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst." erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung} fehler={zustand.felder?.bezeichnung}
pflicht pflicht
> >
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} /> <Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld> </Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}> <Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} /> <Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld> </Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht> <Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}> <Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => ( {VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}> <option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]} {VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}> <Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}> <Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => ( {VERTRAG_STATUS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]} {VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="vertragsnummer" id="vertragsnummer"
beschriftung="Vertragsnummer" beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu." erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer} fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} /> <Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Laufzeit und Kündigung" titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag." beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
> >
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}> <Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} /> <Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld> </Feld>
<Feld <Feld
id="endeAm" id="endeAm"
beschriftung="Vereinbartes Ende" beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet." erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
fehler={zustand.felder?.endeAm} fehler={zustand.felder?.endeAm}
> >
<Input name="endeAm" type="date" defaultValue={werte.endeAm} /> <Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld> </Feld>
<Feld <Feld
id="kuendigungsfristMonate" id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten" beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate." erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate} fehler={zustand.felder?.kuendigungsfristMonate}
> >
<Input <Input
name="kuendigungsfristMonate" name="kuendigungsfristMonate"
inputMode="numeric" inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate} defaultValue={werte.kuendigungsfristMonate}
/> />
</Feld> </Feld>
<Feld <Feld
id="kuendigungZum" id="kuendigungZum"
beschriftung="Gekündigt zum" beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum} fehler={zustand.felder?.kuendigungZum}
> >
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} /> <Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld> </Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}> <Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} /> <Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht."> <Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}> <Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" /> <Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld> </Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}> <Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}> <Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option> <option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => ( {ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]} {RHYTHMUS_BESCHRIFTUNG[r]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Bewertung fürs Sozialamt" titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft." beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
> >
<Feld <Feld
id="bewertungSozialhilfe" id="bewertungSozialhilfe"
beschriftung="Einordnung" beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]} erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe} fehler={zustand.felder?.bewertungSozialhilfe}
> >
<Select <Select
name="bewertungSozialhilfe" name="bewertungSozialhilfe"
value={bewertung} value={bewertung}
onChange={(e) => setBewertung(e.target.value)} onChange={(e) => setBewertung(e.target.value)}
> >
{BEWERTUNGEN.map((b) => ( {BEWERTUNGEN.map((b) => (
<option key={b} value={b}> <option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]} {BEWERTUNG_BESCHRIFTUNG[b]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="bewertungBegruendung" id="bewertungBegruendung"
beschriftung="Begründung" beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren." erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung} fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'} pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Textarea <Textarea
name="bewertungBegruendung" name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung} defaultValue={werte.bewertungBegruendung}
rows={4} rows={4}
maxLength={4000} maxLength={4000}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld <Feld
id="notizen" id="notizen"
beschriftung="Notizen" beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert." erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen} fehler={zustand.felder?.notizen}
> >
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} /> <Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld> </Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf> <Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form> </form>
) )
} }
@@ -36,6 +36,7 @@ export type VorgangsFormularWerte = {
beschreibung: string | null beschreibung: string | null
gegenstelle: string | null gegenstelle: string | null
aktenzeichen: string | null aktenzeichen: string | null
ansprechpartnerId: string | null
verantwortlich: string | null verantwortlich: string | null
naechsterSchritt: string | null naechsterSchritt: string | null
faelligAm: Date | null faelligAm: Date | null
@@ -50,6 +51,7 @@ export function VorgangsFormular({
vorlageSchluessel, vorlageSchluessel,
werte, werte,
abbrechenHref, abbrechenHref,
kontakte,
}: { }: {
modus: 'anlegen' | 'aendern' modus: 'anlegen' | 'aendern'
betreuteId: string betreuteId: string
@@ -57,6 +59,8 @@ export function VorgangsFormular({
vorlageSchluessel?: string | null vorlageSchluessel?: string | null
werte: VorgangsFormularWerte werte: VorgangsFormularWerte
abbrechenHref: string abbrechenHref: string
/** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */
kontakte: { id: string; name: string; organisation: string | null }[]
}) { }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>( const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern, modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
@@ -146,6 +150,22 @@ export function VorgangsFormular({
/> />
</Feld> </Feld>
<Feld
id="ansprechpartnerId"
beschriftung="Ansprechpartner"
erklaerung="Wer dort die Sache bearbeitet. Schreiben zu diesem Vorgang reden ihn namentlich an — das bringt sie an der Poststelle vorbei."
fehler={zustand.felder?.ansprechpartnerId}
>
<Select name="ansprechpartnerId" defaultValue={werte.ansprechpartnerId ?? ''}>
<option value="">– keiner benannt –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{[kontakt.name, kontakt.organisation].filter(Boolean).join(' · ')}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen" beschriftung="Aktenzeichen"
+92
View File
@@ -0,0 +1,92 @@
import 'server-only'
import { env } from '@/lib/env'
/**
* Von welchem Anschluss kommt diese Anfrage?
*
* Gebraucht wird das nur fuer eines: die zweite Dimension der Anmeldesperre.
* Gezaehlt wird nach E-Mail UND nach Anschluss, damit es weder hilft,
* dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem.
*
* ## Warum das vorher nichts geschuetzt hat
*
* Der Wert kam aus "x-forwarded-for", und zwar aus dem ERSTEN Eintrag:
*
* kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
*
* Dieser Kopf ist eine Liste, an die jeder Zwischenknoten hinten anhaengt.
* Der erste Eintrag ist deshalb der, den der Aufrufer selbst geschickt hat -
* frei erfunden. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah
* fuer die Sperre wie tausend verschiedene Anschluesse aus. Die Zaehlung
* nach Anschluss war damit nicht nur wirkungslos, sondern irrefuehrend: Im
* Protokoll standen tausend Anschluesse, wo einer war.
*
* Richtig ist das andere Ende: Was der vertraute Proxy anhaengt, hat er
* selbst gesehen und nicht der Aufrufer geschrieben.
*
* ## Und wenn kein Proxy davor steht
*
* Dann ist der Kopf vollstaendig fremdbestimmt und es gibt hier nichts zu
* holen - also null. Keine Zaehlung nach Anschluss ist besser als eine, die
* sich mit einer Kopfzeile aushebeln laesst. Deshalb muss der Betrieb
* ausdruecklich sagen, dass ein Proxy davorsteht (VERTRAUE_PROXY=true).
*/
let gewarnt = false
/**
* Warnt einmal, wenn weitergereichte Koepfe ankommen, ihnen aber nicht
* geglaubt wird. Genau dann fehlt die Einstellung im Betrieb.
*/
function einmalWarnen(): void {
if (gewarnt) return
gewarnt = true
console.warn(
'[anschluss] Es kommen "x-forwarded-for"-Koepfe an, aber VERTRAUE_PROXY ist nicht gesetzt. ' +
'Die Anmeldesperre zaehlt deshalb nur nach E-Mail-Adresse, nicht nach Anschluss. ' +
'Steht ein Reverse Proxy davor, setze VERTRAUE_PROXY=true (und, bei mehreren, ' +
'VERTRAUE_PROXY_STUFEN=<Anzahl>). Steht keiner davor, ist das hier richtig so.',
)
}
/**
* Liest die Adresse des Aufrufers aus den Koepfen.
*
* @param kopf Die Kopfzeilen der Anfrage.
* @returns Die Adresse, oder null, wenn sie sich nicht vertrauenswuerdig
* bestimmen laesst.
*/
export function anschlussAus(kopf: Headers): string | null {
const weitergereicht = kopf.get('x-forwarded-for')
const echterIp = kopf.get('x-real-ip')
if (!env.vertraueProxy) {
if (weitergereicht || echterIp) einmalWarnen()
return null
}
if (weitergereicht) {
const kette = weitergereicht
.split(',')
.map((teil) => teil.trim())
.filter((teil) => teil.length > 0)
// Von hinten zaehlen: Der letzte Eintrag stammt vom naechsten
// Zwischenknoten, der zweitletzte von dem davor. Bei einer Stufe ist
// der letzte Eintrag der Aufrufer, wie der Proxy ihn gesehen hat.
if (kette.length > 0) {
const stelle = kette.length - env.vertraueProxyStufen
return kette[Math.max(0, stelle)] ?? null
}
}
// Manche Proxys setzen nur diesen Kopf. Er enthaelt genau eine Adresse,
// also gibt es hier nichts abzuzaehlen.
return echterIp?.trim() || null
}
/** Nur fuer Tests: setzt die einmalige Warnung zurueck. */
export function warnungZuruecksetzen(): void {
gewarnt = false
}

Some files were not shown because too many files have changed in this diff Show More