diff --git a/docs/schema-wuensche/dokumente.md b/docs/schema-wuensche/dokumente.md index 9e73847..9e540c9 100644 --- a/docs/schema-wuensche/dokumente.md +++ b/docs/schema-wuensche/dokumente.md @@ -1,187 +1,187 @@ -# Schema-Wünsche aus Etappe 3 „Papier“ - -Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde -absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und -funktioniert, wäre aber sauberer, wenn es im Modell stünde. - -Reihenfolge: was am meisten weh tut, steht oben. - ---- - -## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen - -**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt -„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau -so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, -Prüfung in `dokumentVerknuepfen`). - -**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein -Seed oder ein späterer Import das nicht verletzen kann: - -```sql -ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( - (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int - + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int - + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int - + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" 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 -); -``` - -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 -auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr -erklärbar. - -**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen -werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei -Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, -z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") -WHERE "vorgangId" IS NOT NULL;`, würde das schließen. - ---- - -## 2. `Dokument.ocrStatus` ist ein freier String - -**Stand:** `ocrStatus String?` mit dem Kommentar -`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. - -**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 -(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt -heute erst auf, wenn das Abzeichen leer bleibt. - ---- - -## 3. Kein Index auf `Dokument.objektSchluessel` - -**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das -Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu -bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. - -**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also -ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. - ---- - -## 4. Keine Dublettenerkennung über die Prüfsumme - -**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer -denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. - -**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 -zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). - ---- - -## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` - -**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim -Verknüpfen muss deshalb über die Beziehung gehen -(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). - -**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen -Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ -ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und -Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler -passieren. - ---- - -## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel - -**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein -OCR-Lauf zuletzt versucht wurde, steht nirgends. - -**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in -`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf -`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein -dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es -bei jedem Nachziehen erneut durch Tesseract zu schicken. - ---- - -## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien - -Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die -festgehalten gehört. Bei `Dokument.ocrText` steht: - -> „die Datei selbst liegt verschlüsselt im Objektspeicher“ - -Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` -verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- -Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus -`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für -`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — -ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort -läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den -AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. - -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. - -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, -entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber -konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen -berührt. - ---- - -# Anhang: kein Schema-Wunsch, aber dringend - -## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen - -Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, -also auch die Berichte aus Etappe 5. - -**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit - -``` -TypeError: Cannot read properties of undefined (reading 'unitsPerEm') - at @react-pdf/textkit/lib/textkit.js:3090 -``` - -— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist -schuld: in reinem Node reproduziert es genauso. - -**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` -festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind -inzwischen weitergewandert: - -| Paket | installiert | von renderer 4.1.6 erwartet | -|---|---|---| -| `@react-pdf/font` | 3.1.0 | `^3.0.1` | -| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | -| `@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 -löste die Schicht darunter den bloßen Namen selbst auf. -`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit -`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen -beiden klafft eine Lücke. - -**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende -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 -Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, -alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: -„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): -`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — - -```jsonc -"overrides": { - "@react-pdf/layout": "4.2.x", - "@react-pdf/textkit": "5.x" -} -``` - -Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ -`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen -Unterabhängigkeiten wieder zusammenpassen. Danach kann -`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` -merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. +# Schema-Wünsche aus Etappe 3 „Papier“ + +Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde +absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und +funktioniert, wäre aber sauberer, wenn es im Modell stünde. + +Reihenfolge: was am meisten weh tut, steht oben. + +--- + +## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen + +**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt +„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau +so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, +Prüfung in `dokumentVerknuepfen`). + +**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein +Seed oder ein späterer Import das nicht verletzen kann: + +```sql +ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( + (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int + + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int + + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int + + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" 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 +); +``` + +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 +auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr +erklärbar. + +**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen +werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei +Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, +z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") +WHERE "vorgangId" IS NOT NULL;`, würde das schließen. + +--- + +## 2. `Dokument.ocrStatus` ist ein freier String + +**Stand:** `ocrStatus String?` mit dem Kommentar +`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. + +**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 +(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt +heute erst auf, wenn das Abzeichen leer bleibt. + +--- + +## 3. Kein Index auf `Dokument.objektSchluessel` + +**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das +Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu +bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. + +**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also +ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. + +--- + +## 4. Keine Dublettenerkennung über die Prüfsumme + +**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer +denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. + +**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 +zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). + +--- + +## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` + +**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim +Verknüpfen muss deshalb über die Beziehung gehen +(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). + +**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen +Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ +ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und +Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler +passieren. + +--- + +## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel + +**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein +OCR-Lauf zuletzt versucht wurde, steht nirgends. + +**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in +`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf +`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein +dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es +bei jedem Nachziehen erneut durch Tesseract zu schicken. + +--- + +## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien + +Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die +festgehalten gehört. Bei `Dokument.ocrText` steht: + +> „die Datei selbst liegt verschlüsselt im Objektspeicher“ + +Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` +verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- +Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus +`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für +`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — +ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort +läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den +AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. + +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. + +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, +entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber +konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen +berührt. + +--- + +# Anhang: kein Schema-Wunsch, aber dringend + +## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen + +Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, +also auch die Berichte aus Etappe 5. + +**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit + +``` +TypeError: Cannot read properties of undefined (reading 'unitsPerEm') + at @react-pdf/textkit/lib/textkit.js:3090 +``` + +— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist +schuld: in reinem Node reproduziert es genauso. + +**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` +festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind +inzwischen weitergewandert: + +| Paket | installiert | von renderer 4.1.6 erwartet | +|---|---|---| +| `@react-pdf/font` | 3.1.0 | `^3.0.1` | +| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | +| `@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 +löste die Schicht darunter den bloßen Namen selbst auf. +`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit +`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen +beiden klafft eine Lücke. + +**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende +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 +Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, +alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: +„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): +`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — + +```jsonc +"overrides": { + "@react-pdf/layout": "4.2.x", + "@react-pdf/textkit": "5.x" +} +``` + +Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ +`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen +Unterabhängigkeiten wieder zusammenpassen. Danach kann +`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` +merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. diff --git a/docs/schema-wuensche/sozialhilfe.md b/docs/schema-wuensche/sozialhilfe.md index 0b37977..d0fd5ea 100644 --- a/docs/schema-wuensche/sozialhilfe.md +++ b/docs/schema-wuensche/sozialhilfe.md @@ -1,243 +1,243 @@ -# Schema-Wünsche aus Etappe 5 „Behörde“ - -Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls -gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem -Behelf, der jetzt an seiner Stelle steht. - ---- - -## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig - -Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus -Sicht des Moduls, das die Gegenüberstellung baut. - -**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher -Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der -Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile -zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal -gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei -Pflegegrad 3 um 1.319 Euro im Monat. - -**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart -`EIGENANTEIL_NETTO` (siehe Kopfkommentar in -`src/lib/sozialhilfe/heimkosten.ts`): - -``` -Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung) - + unterkunftVerpflegung + investitionskosten - + ausbildungsumlage + zusatzleistungen - + barbetrag + bekleidungspauschale - -Deckung = leistungszuschlag + rente + sonstigeEinkuenfte - + sozialhilfe + eigenmittel - -Deckungslücke = Bedarf − Deckung -``` - -`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als -`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich -null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die -Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor. - -`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der -Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht -auf der Heimrechnung als eigene Abzugsposition. - -Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart -`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung -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 -speichern. - -**Wunsch, in dieser Reihenfolge:** - -1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und - nicht nur hier: - -```prisma -/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der -/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch -/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT -/// als Deckung, sondern nur nachrichtlich. -pflegeEigenanteilCent BigInt @default(0) -``` - -2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes - Modul raten, was in der Zeile steht: - -```prisma -enum Kostenlesart { - EIGENANTEIL_NETTO - PFLEGESATZ_BRUTTO -} - -lesart Kostenlesart @default(EIGENANTEIL_NETTO) -``` - -3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den - Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und - die Summe könnte gegen sie geprüft werden. - -**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul, -das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto- -Eigenanteil ein und lässt `pflegekasseCent` auf 0. - ---- - -## 2. `Nachforderung.checklistenSchluessel` — wichtig - -```prisma -/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts. -/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen. -checklistenSchluessel String? - -@@unique([antragId, checklistenSchluessel]) -``` - -**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der -Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels -nicht dieselbe Unterlage noch einmal anzulegen, vergleicht -`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben, -getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es, -entsteht beim nächsten Übernehmen eine Dublette. - -**Behelf**: Vergleich über die Bezeichnung in -`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`. - ---- - -## 3. Rückkaufswert am Vertrag — wichtig - -```prisma -model Vertrag { - // ... - rueckkaufswertCent BigInt? - rueckkaufswertStichtag DateTime? @db.Date -} -``` - -**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge -nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`, -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 -Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung -zusätzlich ein Vermögensposten angelegt wurde. - -Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der -Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen. - -**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen -Wert haben können (`MIT_RUECKKAUFSWERT` aus -`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht -ermittelt“. Wer den Betrag braucht, legt zusätzlich einen -`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der -Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt. - ---- - -## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel - -```prisma -/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet. -rechtsbehelfsbelehrungVorhanden Boolean @default(true) -rechtsbehelfsbelehrungBemerkung String? -``` - -**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“). -Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen — -das trägt, verliert aber die Begründung, und die bräuchte man genau dann, -wenn man sich im Widerspruchsverfahren darauf beruft. - -**Behelf**: `belehrungZuMonaten()` in -`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf -Weiteres in `notizenVerschluesselt`. - ---- - -## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel - -```prisma -widerspruchFristId String? @unique -widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull) -``` - -**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über -`Frist.systemSchluessel = "sozialhilfe-widerspruch-"` gefunden. -Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist -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 -mitziehen. Dasselbe gilt für -`"sozialhilfe-nachforderung-"`. - -**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`. - ---- - -## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel - -```prisma -/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid. -gezahltCent BigInt? -gezahltAm DateTime? @db.Date -rechnungsnummer String? -``` - -**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was -tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz -zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim -Träger nachzufragen. - -**Behelf**: `bemerkung`. - ---- - -## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein - -```prisma -/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung. -abwesenheitsminderungCent BigInt @default(0) -``` - -**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt -`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach -25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die -Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich -nicht festhalten. - -**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und -neben die erfasste Lücke gestellt, nicht in sie hineingerechnet. - ---- - -## 8. `Leistung.wiederholungsfristMonat/-tag` — klein - -```prisma -/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr. -antragsschlussMonat Int? -antragsschlussTag Int? -``` - -**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden -muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine -Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten, -statt sie von Hand anzulegen. - -**Behelf**: `antragsschluss` als Text im Katalog -(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der -Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit -`landespflegegeld-` (siehe `docs/SCHNITTSTELLEN.md`). - ---- - -## 9. `Nachforderung.notizenVerschluesselt` — klein - -**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im -Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht -angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten -(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die -Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas. - -**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie -verschlüsselt werden sollen, gehört `Nachforderung` in -`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`. +# Schema-Wünsche aus Etappe 5 „Behörde“ + +Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls +gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem +Behelf, der jetzt an seiner Stelle steht. + +--- + +## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig + +Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus +Sicht des Moduls, das die Gegenüberstellung baut. + +**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher +Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der +Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile +zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal +gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei +Pflegegrad 3 um 1.319 Euro im Monat. + +**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart +`EIGENANTEIL_NETTO` (siehe Kopfkommentar in +`src/lib/sozialhilfe/heimkosten.ts`): + +``` +Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung) + + unterkunftVerpflegung + investitionskosten + + ausbildungsumlage + zusatzleistungen + + barbetrag + bekleidungspauschale + +Deckung = leistungszuschlag + rente + sonstigeEinkuenfte + + sozialhilfe + eigenmittel + +Deckungslücke = Bedarf − Deckung +``` + +`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als +`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich +null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die +Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor. + +`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der +Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht +auf der Heimrechnung als eigene Abzugsposition. + +Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart +`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung +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 +speichern. + +**Wunsch, in dieser Reihenfolge:** + +1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und + nicht nur hier: + +```prisma +/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der +/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch +/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT +/// als Deckung, sondern nur nachrichtlich. +pflegeEigenanteilCent BigInt @default(0) +``` + +2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes + Modul raten, was in der Zeile steht: + +```prisma +enum Kostenlesart { + EIGENANTEIL_NETTO + PFLEGESATZ_BRUTTO +} + +lesart Kostenlesart @default(EIGENANTEIL_NETTO) +``` + +3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den + Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und + die Summe könnte gegen sie geprüft werden. + +**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul, +das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto- +Eigenanteil ein und lässt `pflegekasseCent` auf 0. + +--- + +## 2. `Nachforderung.checklistenSchluessel` — wichtig + +```prisma +/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts. +/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen. +checklistenSchluessel String? + +@@unique([antragId, checklistenSchluessel]) +``` + +**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der +Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels +nicht dieselbe Unterlage noch einmal anzulegen, vergleicht +`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben, +getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es, +entsteht beim nächsten Übernehmen eine Dublette. + +**Behelf**: Vergleich über die Bezeichnung in +`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`. + +--- + +## 3. Rückkaufswert am Vertrag — wichtig + +```prisma +model Vertrag { + // ... + rueckkaufswertCent BigInt? + rueckkaufswertStichtag DateTime? @db.Date +} +``` + +**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge +nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`, +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 +Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung +zusätzlich ein Vermögensposten angelegt wurde. + +Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der +Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen. + +**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen +Wert haben können (`MIT_RUECKKAUFSWERT` aus +`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht +ermittelt“. Wer den Betrag braucht, legt zusätzlich einen +`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der +Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt. + +--- + +## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel + +```prisma +/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet. +rechtsbehelfsbelehrungVorhanden Boolean @default(true) +rechtsbehelfsbelehrungBemerkung String? +``` + +**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“). +Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen — +das trägt, verliert aber die Begründung, und die bräuchte man genau dann, +wenn man sich im Widerspruchsverfahren darauf beruft. + +**Behelf**: `belehrungZuMonaten()` in +`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf +Weiteres in `notizenVerschluesselt`. + +--- + +## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel + +```prisma +widerspruchFristId String? @unique +widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull) +``` + +**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über +`Frist.systemSchluessel = "sozialhilfe-widerspruch-"` gefunden. +Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist +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 +mitziehen. Dasselbe gilt für +`"sozialhilfe-nachforderung-"`. + +**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`. + +--- + +## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel + +```prisma +/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid. +gezahltCent BigInt? +gezahltAm DateTime? @db.Date +rechnungsnummer String? +``` + +**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was +tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz +zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim +Träger nachzufragen. + +**Behelf**: `bemerkung`. + +--- + +## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein + +```prisma +/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung. +abwesenheitsminderungCent BigInt @default(0) +``` + +**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt +`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach +25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die +Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich +nicht festhalten. + +**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und +neben die erfasste Lücke gestellt, nicht in sie hineingerechnet. + +--- + +## 8. `Leistung.wiederholungsfristMonat/-tag` — klein + +```prisma +/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr. +antragsschlussMonat Int? +antragsschlussTag Int? +``` + +**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden +muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine +Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten, +statt sie von Hand anzulegen. + +**Behelf**: `antragsschluss` als Text im Katalog +(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der +Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit +`landespflegegeld-` (siehe `docs/SCHNITTSTELLEN.md`). + +--- + +## 9. `Nachforderung.notizenVerschluesselt` — klein + +**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im +Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht +angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten +(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die +Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas. + +**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie +verschlüsselt werden sollen, gehört `Nachforderung` in +`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`. diff --git a/src/app/(app)/berichte/page.tsx b/src/app/(app)/berichte/page.tsx index d073b2f..c7d87b4 100644 --- a/src/app/(app)/berichte/page.tsx +++ b/src/app/(app)/berichte/page.tsx @@ -1,163 +1,163 @@ -import Link from 'next/link' -import { - Archive, - CalendarClock, - FolderArchive, - Paperclip, - ScrollText, -} from 'lucide-react' -import { SeitenKopf } from '@/components/seiten-kopf' -import { Rechtshinweis } from '@/components/rechtshinweis' -import { Badge } from '@/components/ui/badge' -import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { BerichtsListe } from '@/components/berichte/berichts-liste' -import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext' -import { HinweisKarten } from '@/components/hinweise/HinweisKarten' -import { db, nichtGeloescht } from '@/lib/db' -import { formatDatum, relativeTage, tageBis } from '@/lib/utils' - -export const metadata = { title: 'Berichte' } - -/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */ -const BERICHTSFRISTEN = [ - 'anfangsbericht', - 'jahresbericht', - 'vermoegensverzeichnis', - 'vermoegensuebersicht', - 'rechnungslegung', -] - -const WEGE = [ - { - href: '/berichte/jahresbericht', - titel: 'Jahresbericht', - beschreibung: - 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.', - icon: ScrollText, - }, - { - href: '/berichte/belegmappe', - titel: 'Belegmappe', - beschreibung: - 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.', - icon: FolderArchive, - }, - { - href: '/berichte/unterlagenpaket', - titel: 'Unterlagen für den Träger', - beschreibung: - 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.', - icon: Paperclip, - }, - { - href: '/berichte/export', - titel: 'Vollexport', - beschreibung: - 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.', - icon: Archive, - }, -] - -/** - * Die Berichtsuebersicht beantwortet zwei Fragen: - * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?". - * - * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen - * Pflichten mit Datum. Die zweite aus den protokollierten Berichten. - */ -export default async function BerichteSeite() { - const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') - - const [fristen, zeilen] = await Promise.all([ - db.frist.findMany({ - where: { - betreuteId, - status: 'OFFEN', - ...nichtGeloescht, - OR: BERICHTSFRISTEN.map((schluessel) => ({ - systemSchluessel: { startsWith: schluessel }, - })), - }, - orderBy: { datum: 'asc' }, - take: 10, - }), - berichtsZeilenLaden(betreuteId, darfBerichteSchreiben), - ]) - - return ( -
- - -
-

Demnächst fällig

- {fristen.length === 0 ? ( -

- Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '} - - Fristen - - , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind. -

- ) : ( -
    - {fristen.map((frist) => { - const tage = tageBis(frist.datum) - const variante = - tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen' - return ( -
  • -
  • - ) - })} -
- )} -
- -
- {WEGE.map((weg) => { - const Icon = weg.icon - return ( - - - - - ) - })} -
- -
-

Was ich abgegeben habe

- -
- - - - -
- ) -} +import Link from 'next/link' +import { + Archive, + CalendarClock, + FolderArchive, + Paperclip, + ScrollText, +} from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Badge } from '@/components/ui/badge' +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { BerichtsListe } from '@/components/berichte/berichts-liste' +import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext' +import { HinweisKarten } from '@/components/hinweise/HinweisKarten' +import { db, nichtGeloescht } from '@/lib/db' +import { formatDatum, relativeTage, tageBis } from '@/lib/utils' + +export const metadata = { title: 'Berichte' } + +/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */ +const BERICHTSFRISTEN = [ + 'anfangsbericht', + 'jahresbericht', + 'vermoegensverzeichnis', + 'vermoegensuebersicht', + 'rechnungslegung', +] + +const WEGE = [ + { + href: '/berichte/jahresbericht', + titel: 'Jahresbericht', + beschreibung: + 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.', + icon: ScrollText, + }, + { + href: '/berichte/belegmappe', + titel: 'Belegmappe', + beschreibung: + 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.', + icon: FolderArchive, + }, + { + href: '/berichte/unterlagenpaket', + titel: 'Unterlagen für den Träger', + beschreibung: + 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.', + icon: Paperclip, + }, + { + href: '/berichte/export', + titel: 'Vollexport', + beschreibung: + 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.', + icon: Archive, + }, +] + +/** + * Die Berichtsuebersicht beantwortet zwei Fragen: + * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?". + * + * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen + * Pflichten mit Datum. Die zweite aus den protokollierten Berichten. + */ +export default async function BerichteSeite() { + const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') + + const [fristen, zeilen] = await Promise.all([ + db.frist.findMany({ + where: { + betreuteId, + status: 'OFFEN', + ...nichtGeloescht, + OR: BERICHTSFRISTEN.map((schluessel) => ({ + systemSchluessel: { startsWith: schluessel }, + })), + }, + orderBy: { datum: 'asc' }, + take: 10, + }), + berichtsZeilenLaden(betreuteId, darfBerichteSchreiben), + ]) + + return ( +
+ + +
+

Demnächst fällig

+ {fristen.length === 0 ? ( +

+ Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '} + + Fristen + + , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind. +

+ ) : ( +
    + {fristen.map((frist) => { + const tage = tageBis(frist.datum) + const variante = + tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen' + return ( +
  • +
  • + ) + })} +
+ )} +
+ +
+ {WEGE.map((weg) => { + const Icon = weg.icon + return ( + + + + + ) + })} +
+ +
+

Was ich abgegeben habe

+ +
+ + + + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx b/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx index 01c4ee5..3a8b115 100644 --- a/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx +++ b/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx @@ -1,149 +1,149 @@ -import { notFound } from 'next/navigation' -import { Trash2 } from 'lucide-react' -import { SeitenKopf } from '@/components/seiten-kopf' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' -import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' -import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' -import { oeffnen } from '@/lib/crypto/feld' -import { db, nichtGeloescht } from '@/lib/db' -import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' -import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' -import { betragFormatieren } from '@/lib/finanzen/betrag' -import { - fristAmpel, - rechtsbehelfsfristBerechnen, - zugangsvermutung, -} from '@/lib/sozialhilfe/rechtsbehelf' -import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' -import { bescheidLoeschen } from '@/server/actions/sozialhilfe' -import { formatDatum } from '@/lib/utils' - -export const dynamic = 'force-dynamic' - -/** - * Bescheid-Prüfung. - * - * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die - * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen - * können, wo die Rechnung abgebogen ist. - */ -export default async function BescheidSeite({ - params, -}: { - params: Promise<{ antragId: string; bescheidId: string }> -}) { - const { antragId, bescheidId } = await params - const { betreuteId, darfSchreiben } = await sozialhilfeKontext() - - const bescheid = await db.bescheid.findFirst({ - where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, - include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, - }) - if (!bescheid) notFound() - - const offen = oeffnen('Bescheid', bescheid) - const heute = heuteAlsTag() - - const ergebnis = rechtsbehelfsfristBerechnen({ - zugegangenAm: bescheid.zugegangenAm, - bescheidVom: bescheid.bescheidVom, - fristMonate: bescheid.rechtsbehelfsfristMonate, - heute, - }) - - const vorschlag = zugangsvermutung(bescheid.bescheidVom) - - return ( -
- - {bescheid.antrag.traeger} - {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} - {formatDatum(bescheid.zugegangenAm)} - - } - pfad={[ - { titel: 'Start', href: '/heute' }, - { titel: 'Sozialhilfe', href: '/sozialhilfe' }, - { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, - { titel: 'Bescheid' }, - ]} - /> - - - - {offen.notizen && ( - - - Notizen - Verschlüsselt gespeichert. - - -

{offen.notizen}

-
-
- )} - - {darfSchreiben && ( -
-

Bescheid bearbeiten

- - -
- - -
-
- )} -
- ) -} +import { notFound } from 'next/navigation' +import { Trash2 } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' +import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' +import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' +import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' +import { betragFormatieren } from '@/lib/finanzen/betrag' +import { + fristAmpel, + rechtsbehelfsfristBerechnen, + zugangsvermutung, +} from '@/lib/sozialhilfe/rechtsbehelf' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { bescheidLoeschen } from '@/server/actions/sozialhilfe' +import { formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +/** + * Bescheid-Prüfung. + * + * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die + * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen + * können, wo die Rechnung abgebogen ist. + */ +export default async function BescheidSeite({ + params, +}: { + params: Promise<{ antragId: string; bescheidId: string }> +}) { + const { antragId, bescheidId } = await params + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const bescheid = await db.bescheid.findFirst({ + where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, + include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, + }) + if (!bescheid) notFound() + + const offen = oeffnen('Bescheid', bescheid) + const heute = heuteAlsTag() + + const ergebnis = rechtsbehelfsfristBerechnen({ + zugegangenAm: bescheid.zugegangenAm, + bescheidVom: bescheid.bescheidVom, + fristMonate: bescheid.rechtsbehelfsfristMonate, + heute, + }) + + const vorschlag = zugangsvermutung(bescheid.bescheidVom) + + return ( +
+ + {bescheid.antrag.traeger} + {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} + {formatDatum(bescheid.zugegangenAm)} + + } + pfad={[ + { titel: 'Start', href: '/heute' }, + { titel: 'Sozialhilfe', href: '/sozialhilfe' }, + { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, + { titel: 'Bescheid' }, + ]} + /> + + + + {offen.notizen && ( + + + Notizen + Verschlüsselt gespeichert. + + +

{offen.notizen}

+
+
+ )} + + {darfSchreiben && ( +
+

Bescheid bearbeiten

+ + +
+ + +
+
+ )} +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/vermoegen/page.tsx b/src/app/(app)/sozialhilfe/vermoegen/page.tsx index 46926df..004c695 100644 --- a/src/app/(app)/sozialhilfe/vermoegen/page.tsx +++ b/src/app/(app)/sozialhilfe/vermoegen/page.tsx @@ -1,232 +1,232 @@ -import { SeitenKopf } from '@/components/seiten-kopf' -import { Rechtshinweis } from '@/components/rechtshinweis' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Card, CardContent } from '@/components/ui/card' -import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' -import { oeffnen } from '@/lib/crypto/feld' -import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { alsIsoTag } from '@/lib/fristen/datum' -import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' -import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' -import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' -import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' -import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' -import { formatCent } from '@/lib/utils' -import { AlertCircle } from 'lucide-react' - -export const dynamic = 'force-dynamic' - -/** - * Vermögens-Bewertungsansicht. - * - * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was - * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. - * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". - */ -export default async function VermoegensbewertungSeite() { - const { betreuteId, darfSchreiben } = await sozialhilfeKontext() - - const [posten, vertraege] = await Promise.all([ - db.vermoegensposten.findMany({ - where: { betreuteId, ...nichtGeloescht }, - include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, - orderBy: { bezeichnung: 'asc' }, - }), - db.vertrag.findMany({ - where: { - betreuteId, - // Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein - // Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen. - art: { in: [...MIT_RUECKKAUFSWERT] }, - ...nichtGeloescht, - }, - orderBy: { bezeichnung: 'asc' }, - }), - ]) - - // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt - // zeigen - sonst wäre die Summe zweimal drin. - const bereitsAlsPosten = new Set( - posten.map((p) => p.vertragId).filter((id): id is string => id !== null), - ) - - const zeilen: ListenPosten[] = [ - ...posten.map((p) => { - const geoeffnet = oeffnen('Vermoegensposten', p) - const stand = p.wertstaende[0] - return { - id: p.id, - bezeichnung: p.bezeichnung, - herkunft: 'VERMOEGEN' as const, - art: p.art, - artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art], - bewertung: p.bewertungSozialhilfe, - wertCent: stand ? centZuZahl(stand.wertCent) : null, - wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, - begruendung: geoeffnet.bewertungBegruendung ?? '', - zusatz: stand ? stand.wertquelle : null, - } - }), - ...vertraege - .filter((v) => !bereitsAlsPosten.has(v.id)) - .map((v) => { - const geoeffnet = oeffnen('Vertrag', v) - return { - id: v.id, - bezeichnung: v.bezeichnung, - herkunft: 'VERTRAG' as const, - art: v.art, - artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art], - bewertung: v.bewertungSozialhilfe, - // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird - // als Vermögensposten mit Wertstand geführt. Bis dahin: offen. - wertCent: null, - wertStichtag: null, - begruendung: geoeffnet.bewertungBegruendung ?? '', - zusatz: v.gesellschaft, - } - }), - ] - - const uebersicht = uebersichtBilden( - zeilen.map((z) => ({ - id: z.id, - bezeichnung: z.bezeichnung, - herkunft: z.herkunft, - artBeschriftung: z.artBeschriftung, - bewertung: z.bewertung, - wertCent: z.wertCent === null ? null : BigInt(z.wertCent), - wertStichtag: null, - begruendung: z.begruendung, - href: '#', - })), - ) - - const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) - - return ( -
- - -
- - - - -
- - - -
- - Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) - - - {formatCent(vergleich.freibetragCent)} - -
-
- Über dem Freibetrag - - {formatCent(vergleich.ueberFreibetragCent)} - -
-

{vergleich.hinweis}

- -
-
- - {uebersicht.ungeprueft.length > 0 && ( - - - )} - - {uebersicht.ohneBegruendung.length > 0 && ( - - - )} - - - - -
- ) -} - -function Summenkachel({ - titel, - betrag, - anzahl, - ohneWert, - farbe, -}: { - titel: string - betrag: bigint - anzahl: number - ohneWert: number - farbe: string -}) { - return ( - - -

{titel}

-

{formatCent(betrag)}

-

- {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} - {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} -

-
-
- ) -} +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent } from '@/components/ui/card' +import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' +import { oeffnen } from '@/lib/crypto/feld' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag } from '@/lib/fristen/datum' +import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' +import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' +import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' +import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { formatCent } from '@/lib/utils' +import { AlertCircle } from 'lucide-react' + +export const dynamic = 'force-dynamic' + +/** + * Vermögens-Bewertungsansicht. + * + * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was + * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. + * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". + */ +export default async function VermoegensbewertungSeite() { + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const [posten, vertraege] = await Promise.all([ + db.vermoegensposten.findMany({ + where: { betreuteId, ...nichtGeloescht }, + include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, + orderBy: { bezeichnung: 'asc' }, + }), + db.vertrag.findMany({ + where: { + betreuteId, + // Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein + // Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen. + art: { in: [...MIT_RUECKKAUFSWERT] }, + ...nichtGeloescht, + }, + orderBy: { bezeichnung: 'asc' }, + }), + ]) + + // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt + // zeigen - sonst wäre die Summe zweimal drin. + const bereitsAlsPosten = new Set( + posten.map((p) => p.vertragId).filter((id): id is string => id !== null), + ) + + const zeilen: ListenPosten[] = [ + ...posten.map((p) => { + const geoeffnet = oeffnen('Vermoegensposten', p) + const stand = p.wertstaende[0] + return { + id: p.id, + bezeichnung: p.bezeichnung, + herkunft: 'VERMOEGEN' as const, + art: p.art, + artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art], + bewertung: p.bewertungSozialhilfe, + wertCent: stand ? centZuZahl(stand.wertCent) : null, + wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, + begruendung: geoeffnet.bewertungBegruendung ?? '', + zusatz: stand ? stand.wertquelle : null, + } + }), + ...vertraege + .filter((v) => !bereitsAlsPosten.has(v.id)) + .map((v) => { + const geoeffnet = oeffnen('Vertrag', v) + return { + id: v.id, + bezeichnung: v.bezeichnung, + herkunft: 'VERTRAG' as const, + art: v.art, + artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art], + bewertung: v.bewertungSozialhilfe, + // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird + // als Vermögensposten mit Wertstand geführt. Bis dahin: offen. + wertCent: null, + wertStichtag: null, + begruendung: geoeffnet.bewertungBegruendung ?? '', + zusatz: v.gesellschaft, + } + }), + ] + + const uebersicht = uebersichtBilden( + zeilen.map((z) => ({ + id: z.id, + bezeichnung: z.bezeichnung, + herkunft: z.herkunft, + artBeschriftung: z.artBeschriftung, + bewertung: z.bewertung, + wertCent: z.wertCent === null ? null : BigInt(z.wertCent), + wertStichtag: null, + begruendung: z.begruendung, + href: '#', + })), + ) + + const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) + + return ( +
+ + +
+ + + + +
+ + + +
+ + Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) + + + {formatCent(vergleich.freibetragCent)} + +
+
+ Über dem Freibetrag + + {formatCent(vergleich.ueberFreibetragCent)} + +
+

{vergleich.hinweis}

+ +
+
+ + {uebersicht.ungeprueft.length > 0 && ( + + + )} + + {uebersicht.ohneBegruendung.length > 0 && ( + + + )} + + + + +
+ ) +} + +function Summenkachel({ + titel, + betrag, + anzahl, + ohneWert, + farbe, +}: { + titel: string + betrag: bigint + anzahl: number + ohneWert: number + farbe: string +}) { + return ( + + +

{titel}

+

{formatCent(betrag)}

+

+ {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} + {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} +

+
+
+ ) +} diff --git a/src/app/(app)/vertraege/[id]/bearbeiten/page.tsx b/src/app/(app)/vertraege/[id]/bearbeiten/page.tsx index 3a32a10..23af48e 100644 --- a/src/app/(app)/vertraege/[id]/bearbeiten/page.tsx +++ b/src/app/(app)/vertraege/[id]/bearbeiten/page.tsx @@ -1,77 +1,77 @@ -import { notFound, redirect } from 'next/navigation' -import { SeitenKopf } from '@/components/seiten-kopf' -import { VertragFormular } from '@/components/vertraege/vertrag-formular' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { db, nichtGeloescht } from '@/lib/db' -import { oeffnen } from '@/lib/crypto/feld' -import { alsIsoTag } from '@/lib/fristen/datum' - -export const dynamic = 'force-dynamic' - -/** Cent als Eingabetext: 481255 wird zu "4812,55". */ -function centAlsText(cent: bigint | null): string { - if (cent === null) return '' - const negativ = cent < 0n - const betrag = negativ ? -cent : cent - const euro = betrag / 100n - const rest = betrag % 100n - return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}` -} - -export default async function VertragBearbeiten({ - params, -}: { - params: Promise<{ id: string }> -}) { - const { id } = await params - const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`) - const aktive = await aktiveBetreute(benutzer) - if (!aktive) return null - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') - if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`) - - const vertrag = await db.vertrag.findFirst({ - where: { id, betreuteId: aktive.id, ...nichtGeloescht }, - }) - if (!vertrag) notFound() - - const offen = oeffnen('Vertrag', vertrag) - - return ( -
- - - -
- ) -} +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { VertragFormular } from '@/components/vertraege/vertrag-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { alsIsoTag } from '@/lib/fristen/datum' + +export const dynamic = 'force-dynamic' + +/** Cent als Eingabetext: 481255 wird zu "4812,55". */ +function centAlsText(cent: bigint | null): string { + if (cent === null) return '' + const negativ = cent < 0n + const betrag = negativ ? -cent : cent + const euro = betrag / 100n + const rest = betrag % 100n + return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}` +} + +export default async function VertragBearbeiten({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') + if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`) + + const vertrag = await db.vertrag.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + }) + if (!vertrag) notFound() + + const offen = oeffnen('Vertrag', vertrag) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/vertraege/[id]/kuendigen/page.tsx b/src/app/(app)/vertraege/[id]/kuendigen/page.tsx index e3278d1..a55dc8a 100644 --- a/src/app/(app)/vertraege/[id]/kuendigen/page.tsx +++ b/src/app/(app)/vertraege/[id]/kuendigen/page.tsx @@ -1,96 +1,96 @@ -import { notFound } from 'next/navigation' -import { SeitenKopf } from '@/components/seiten-kopf' -import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { db, nichtGeloescht } from '@/lib/db' -import { oeffnen } from '@/lib/crypto/feld' -import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' -import { briefkopfBauen } from '@/lib/vertraege/briefkopf' -import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent' -import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung' -import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' - -export const dynamic = 'force-dynamic' - -export default async function VertragKuendigen({ - params, -}: { - params: Promise<{ id: string }> -}) { - const { id } = await params - const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`) - const aktive = await aktiveBetreute(benutzer) - if (!aktive) return null - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') - const schreiben = darfSchreiben('VERTRAEGE') - - const vertrag = await db.vertrag.findFirst({ - where: { id, betreuteId: aktive.id, ...nichtGeloescht }, - }) - if (!vertrag) notFound() - - const offen = oeffnen('Vertrag', vertrag) - const heute = heuteAlsTag() - - const paket = kuendigungsassistent({ - kopf: briefkopfBauen({ betreute: aktive, benutzer }), - empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, - vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, - vertragsnummer: offen.vertragsnummer, - art: vertrag.art, - laufzeit: { - beginnAm: vertrag.beginnAm, - endeAm: vertrag.endeAm, - laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), - kuendigungsfristMonate: vertrag.kuendigungsfristMonate, - }, - heute, - }) - - return ( -
- - - -
- ) -} +import { notFound } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' +import { briefkopfBauen } from '@/lib/vertraege/briefkopf' +import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent' +import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung' +import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' + +export const dynamic = 'force-dynamic' + +export default async function VertragKuendigen({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') + const schreiben = darfSchreiben('VERTRAEGE') + + const vertrag = await db.vertrag.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + }) + if (!vertrag) notFound() + + const offen = oeffnen('Vertrag', vertrag) + const heute = heuteAlsTag() + + const paket = kuendigungsassistent({ + kopf: briefkopfBauen({ betreute: aktive, benutzer }), + empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, + vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, + vertragsnummer: offen.vertragsnummer, + art: vertrag.art, + laufzeit: { + beginnAm: vertrag.beginnAm, + endeAm: vertrag.endeAm, + laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), + kuendigungsfristMonate: vertrag.kuendigungsfristMonate, + }, + heute, + }) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/vertraege/[id]/page.tsx b/src/app/(app)/vertraege/[id]/page.tsx index afba9b5..cf9e3db 100644 --- a/src/app/(app)/vertraege/[id]/page.tsx +++ b/src/app/(app)/vertraege/[id]/page.tsx @@ -1,275 +1,275 @@ -import Link from 'next/link' -import { notFound } from 'next/navigation' -import { FileX2, Pencil, Shield } from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { SeitenKopf } from '@/components/seiten-kopf' -import { Datenzeile } from '@/components/substanz-formular' -import { Rechtshinweis } from '@/components/rechtshinweis' -import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { oeffnen } from '@/lib/crypto/feld' -import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' -import { briefkopfBauen } from '@/lib/vertraege/briefkopf' -import { - kuendigungsampel, - kuendigungsplan, - laufzeitAusVertrag, -} from '@/lib/vertraege/kuendigung' -import { - anfrageStatus, - rueckkaufswertAnschreiben, - wertHistorie, -} from '@/lib/vertraege/rueckkaufswert' -import { wertalterBestimmen } from '@/lib/finanzen/wertalter' -import { - BEWERTUNG_BESCHRIFTUNG, - BEWERTUNG_ERKLAERUNG, - jahresbeitragCent, - kannRueckkaufswertHaben, - RHYTHMUS_BESCHRIFTUNG, - VERTRAG_ART_BESCHRIFTUNG, - VERTRAG_STATUS_BESCHRIFTUNG, -} from '@/lib/vertraege/konstanten' -import { formatCent, formatDatum } from '@/lib/utils' - -export const dynamic = 'force-dynamic' - -export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const benutzer = await erfordereBenutzer(`/vertraege/${id}`) - const aktive = await aktiveBetreute(benutzer) - if (!aktive) return null - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') - const schreiben = darfSchreiben('VERTRAEGE') - - const vertrag = await db.vertrag.findFirst({ - where: { id, betreuteId: aktive.id, ...nichtGeloescht }, - include: { - // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie(). - vermoegensposten: { - where: nichtGeloescht, - include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, - }, - }, - }) - if (!vertrag) notFound() - - const offen = oeffnen('Vertrag', vertrag) - const heute = heuteAlsTag() - - const plan = kuendigungsplan({ - beginnAm: vertrag.beginnAm, - endeAm: vertrag.endeAm, - laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), - kuendigungsfristMonate: vertrag.kuendigungsfristMonate, - heute, - }) - const ampel = kuendigungsampel(plan) - const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute) - const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) - - const kopf = briefkopfBauen({ betreute: aktive, benutzer }) - const anschreiben = rueckkaufswertAnschreiben({ - kopf, - empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, - vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, - vertragsnummer: offen.vertragsnummer, - stichtag: heute, - heute, - }) - - const wertstaende = wertHistorie(vertrag.vermoegensposten) - - return ( -
- - - -
- ) - } - /> - - - - - - - Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt. - - - -
- - {offen.vertragsnummer ?? '–'} - - - {VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]} - - - {formatDatum(vertrag.beginnAm)} - - - {formatDatum(vertrag.endeAm)} - - - {vertrag.kuendigungsfristMonate !== null - ? `${vertrag.kuendigungsfristMonate} Monate` - : 'nicht erfasst'} - -
-
- - {formatCent(vertrag.beitragCent)} - {vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''} - - {formatCent(jahresbeitrag)} - - {formatDatum(vertrag.gekuendigtAm)} - - - {formatDatum(vertrag.kuendigungZum)} - - - {formatDatum(vertrag.rueckkaufswertAngefragtAm)} - -
- - {offen.notizen && ( -
-

{offen.notizen}

-
- )} -
-
- - - - Kündigungsfrist - - Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der - Absendetag früher als der Fristablauf. - - - - {plan.berechenbar ? ( - <> -
-
-
Nächster Kündigungstermin
-
{formatDatum(plan.naechsterTermin)}
-
-
-
Zugang spätestens
-
- {formatDatum(plan.zugangSpaetestensAm)} -
-
-
-
Brief spätestens absenden
-
- {formatDatum(plan.spaetesterAbsendetag)} -
- {ampel && ( -
- {ampel.text} -
- )} -
-
-
    - {plan.hinweise.map((hinweis) => ( -
  • {hinweis}
  • - ))} -
- - ) : ( -

- {plan.hinweise[0] ?? - 'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'} -

- )} -
-
- - {kannRueckkaufswertHaben(vertrag.art) && ( - ({ - id: w.id, - stichtag: w.stichtag.toISOString(), - wertCent: centZuZahl(w.wertCent) ?? 0, - wertquelle: w.wertquelle, - bemerkung: w.bemerkung, - }))} - /> - )} - - - - Bewertung fürs Sozialamt - - Meine Einordnung und meine Begründung — keine Rechtsauskunft. - - - - {BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]} -

- {BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]} -

- {offen.bewertungBegruendung ? ( -

- {offen.bewertungBegruendung} -

- ) : ( -

- Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer - Aufstellung an den Bezirk auftaucht. -

- )} - -
-
- - ) -} +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { FileX2, Pencil, Shield } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Datenzeile } from '@/components/substanz-formular' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' +import { briefkopfBauen } from '@/lib/vertraege/briefkopf' +import { + kuendigungsampel, + kuendigungsplan, + laufzeitAusVertrag, +} from '@/lib/vertraege/kuendigung' +import { + anfrageStatus, + rueckkaufswertAnschreiben, + wertHistorie, +} from '@/lib/vertraege/rueckkaufswert' +import { wertalterBestimmen } from '@/lib/finanzen/wertalter' +import { + BEWERTUNG_BESCHRIFTUNG, + BEWERTUNG_ERKLAERUNG, + jahresbeitragCent, + kannRueckkaufswertHaben, + RHYTHMUS_BESCHRIFTUNG, + VERTRAG_ART_BESCHRIFTUNG, + VERTRAG_STATUS_BESCHRIFTUNG, +} from '@/lib/vertraege/konstanten' +import { formatCent, formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vertraege/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') + const schreiben = darfSchreiben('VERTRAEGE') + + const vertrag = await db.vertrag.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie(). + vermoegensposten: { + where: nichtGeloescht, + include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, + }, + }, + }) + if (!vertrag) notFound() + + const offen = oeffnen('Vertrag', vertrag) + const heute = heuteAlsTag() + + const plan = kuendigungsplan({ + beginnAm: vertrag.beginnAm, + endeAm: vertrag.endeAm, + laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), + kuendigungsfristMonate: vertrag.kuendigungsfristMonate, + heute, + }) + const ampel = kuendigungsampel(plan) + const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute) + const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) + + const kopf = briefkopfBauen({ betreute: aktive, benutzer }) + const anschreiben = rueckkaufswertAnschreiben({ + kopf, + empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, + vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, + vertragsnummer: offen.vertragsnummer, + stichtag: heute, + heute, + }) + + const wertstaende = wertHistorie(vertrag.vermoegensposten) + + return ( +
+ + + +
+ ) + } + /> + + + + + + + Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt. + + + +
+ + {offen.vertragsnummer ?? '–'} + + + {VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]} + + + {formatDatum(vertrag.beginnAm)} + + + {formatDatum(vertrag.endeAm)} + + + {vertrag.kuendigungsfristMonate !== null + ? `${vertrag.kuendigungsfristMonate} Monate` + : 'nicht erfasst'} + +
+
+ + {formatCent(vertrag.beitragCent)} + {vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''} + + {formatCent(jahresbeitrag)} + + {formatDatum(vertrag.gekuendigtAm)} + + + {formatDatum(vertrag.kuendigungZum)} + + + {formatDatum(vertrag.rueckkaufswertAngefragtAm)} + +
+ + {offen.notizen && ( +
+

{offen.notizen}

+
+ )} +
+
+ + + + Kündigungsfrist + + Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der + Absendetag früher als der Fristablauf. + + + + {plan.berechenbar ? ( + <> +
+
+
Nächster Kündigungstermin
+
{formatDatum(plan.naechsterTermin)}
+
+
+
Zugang spätestens
+
+ {formatDatum(plan.zugangSpaetestensAm)} +
+
+
+
Brief spätestens absenden
+
+ {formatDatum(plan.spaetesterAbsendetag)} +
+ {ampel && ( +
+ {ampel.text} +
+ )} +
+
+
    + {plan.hinweise.map((hinweis) => ( +
  • {hinweis}
  • + ))} +
+ + ) : ( +

+ {plan.hinweise[0] ?? + 'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'} +

+ )} +
+
+ + {kannRueckkaufswertHaben(vertrag.art) && ( + ({ + id: w.id, + stichtag: w.stichtag.toISOString(), + wertCent: centZuZahl(w.wertCent) ?? 0, + wertquelle: w.wertquelle, + bemerkung: w.bemerkung, + }))} + /> + )} + + + + Bewertung fürs Sozialamt + + Meine Einordnung und meine Begründung — keine Rechtsauskunft. + + + + {BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]} +

+ {BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]} +

+ {offen.bewertungBegruendung ? ( +

+ {offen.bewertungBegruendung} +

+ ) : ( +

+ Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer + Aufstellung an den Bezirk auftaucht. +

+ )} + +
+
+ + ) +} diff --git a/src/app/(app)/vorgaenge/[id]/page.tsx b/src/app/(app)/vorgaenge/[id]/page.tsx index 1a04df3..47302e4 100644 --- a/src/app/(app)/vorgaenge/[id]/page.tsx +++ b/src/app/(app)/vorgaenge/[id]/page.tsx @@ -1,438 +1,438 @@ -import { notFound } from 'next/navigation' -import Link from 'next/link' -import { ArrowLeft, FileDown, Clock } from 'lucide-react' -import type { VorgangStatus } from '@prisma/client' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { SeitenKopf } from '@/components/seiten-kopf' -import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' -import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' -import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' -import { - GenehmigungsKette, - type GenehmigungsAnzeige, -} from '@/components/vorgaenge/genehmigungs-kette' -import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' -import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' -import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' -import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' -import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' -import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' -import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' -import { - VORGANG_STATUS_BESCHRIFTUNG, - zeitstrahlBauen, - type ZeitstrahlStatuswechsel, -} from '@/lib/vorgaenge/zeitstrahl' -import { formatDatum } from '@/lib/utils' - -/** - * Die Fallakte. - * - * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; - * darunter die Aufgabenliste, das Genehmigungsverfahren und der - * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. - */ -export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) - const aktive = await aktiveBetreute(benutzer) - if (!aktive) notFound() - - const vorgang = await db.vorgang.findFirst({ - where: { id, betreuteId: aktive.id, ...nichtGeloescht }, - include: { - aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, - genehmigungen: { - where: nichtGeloescht, - include: { beschlussDokument: { select: { id: true, titel: true } } }, - orderBy: { erstelltAm: 'asc' }, - }, - fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, - buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, - kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, - verknuepfungen: { include: { dokument: true } }, - ansprechpartner: true, - }, - }) - if (!vorgang) notFound() - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') - const schreiben = darfSchreiben('VORGAENGE') - - 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 -------------------------------------- - // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die - // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. - const protokolle = await db.auditLog.findMany({ - where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, - orderBy: { zeitpunkt: 'asc' }, - take: 200, - }) - - const statuswechsel: ZeitstrahlStatuswechsel[] = [] - for (const eintrag of protokolle) { - const roh = entschluesselnWennMoeglich( - eintrag.aenderungVerschluesselt, - 'aenderungVerschluesselt', - ) - if (!roh) continue - try { - const gelesen = JSON.parse(roh) as Record - const status = gelesen.status - if (!status || typeof status.neu !== 'string') continue - statuswechsel.push({ - id: eintrag.id, - zeitpunkt: eintrag.zeitpunkt, - von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, - nach: status.neu as VorgangStatus, - wer: eintrag.userName, - }) - } catch { - // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. - continue - } - } - - // --- Zeitstrahl ----------------------------------------------------------- - const zeitstrahl = zeitstrahlBauen({ - vorgang: { - titel: vorgang.titel, - erstelltAm: vorgang.erstelltAm, - erledigtAm: vorgang.erledigtAm, - }, - dokumente: vorgang.verknuepfungen - .map((v) => v.dokument) - .filter((d) => d.geloeschtAm === null) - .map((d) => ({ - id: d.id, - titel: d.titel, - eingangAm: d.eingangAm, - versandAm: d.versandAm, - erstelltAm: d.erstelltAm, - absender: d.absender, - empfaenger: d.empfaenger, - })), - kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { - const klar = oeffnen('Kontaktprotokoll', p) - return { - id: p.id, - datum: p.datum, - art: p.art, - gespraechspartner: p.gespraechspartner, - mitBetreuter: p.mitBetreuter, - inhalt: klar.inhalt ?? null, - } - }), - fristen: vorgang.fristen.map((f) => ({ - id: f.id, - titel: f.titel, - datum: f.datum, - erledigtAm: f.erledigtAm, - ausschlussfrist: f.ausschlussfrist, - rechtsgrundlage: f.rechtsgrundlage, - })), - buchungen: vorgang.buchungen.map((b) => { - const klar = oeffnen('Buchung', b) - return { - id: b.id, - datum: b.datum, - betragCent: centZuZahl(b.betragCent) ?? 0, - richtung: b.richtung, - gegenstelle: b.gegenstelle, - verwendungszweck: klar.verwendungszweck ?? null, - } - }), - genehmigungen: vorgang.genehmigungen.map((g) => ({ - id: g.id, - titel: g.titel, - rechtsgrundlage: g.rechtsgrundlage, - status: g.status, - mitteilungAm: g.mitteilungAm, - antragAm: g.antragAm, - anhoerungAm: g.anhoerungAm, - beschlussAm: g.beschlussAm, - rechtskraftAm: g.rechtskraftAm, - vollzugAm: g.vollzugAm, - })), - statuswechsel, - }) - - // --- Sachstandsanfrage ---------------------------------------------------- - // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der - // Knopf nur Zierde. - const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' - const anfrage = wartetAufDritte - ? sachstandsanfrageErzeugen({ - vorgangTitel: vorgang.titel, - gegenstelle: vorgang.gegenstelle, - aktenzeichen: vorgang.aktenzeichen, - letzterKontaktAm: vorgang.letzterKontaktAm, - betreute: { - vorname: aktive.vorname, - nachname: aktive.nachname, - geburtsdatum: aktive.geburtsdatum, - }, - betreuer: { - name: benutzer.name, - aktenzeichenGericht: aktive.aktenzeichenGericht, - betreuungsgericht: aktive.betreuungsgericht, - }, - ansprechpartner: vorgang.ansprechpartner - ? { - name: vorgang.ansprechpartner.name, - anrede: vorgang.ansprechpartner.anrede, - } - : null, - }) - : null - - // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. - const dokumente = vorgang.genehmigungen.length - ? await db.dokument.findMany({ - where: { betreuteId: aktive.id, ...nichtGeloescht }, - select: { id: true, titel: true }, - orderBy: { erstelltAm: 'desc' }, - take: 100, - }) - : [] - - const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ - id: a.id, - titel: a.titel, - beschreibung: a.beschreibung, - erledigt: a.erledigt, - faelligAm: a.faelligAm, - })) - - const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ - id: g.id, - titel: g.titel, - rechtsgrundlage: g.rechtsgrundlage, - status: g.status, - mitteilungAm: g.mitteilungAm, - antragAm: g.antragAm, - anhoerungAm: g.anhoerungAm, - beschlussAm: g.beschlussAm, - rechtskraftAm: g.rechtskraftAm, - vollzugAm: g.vollzugAm, - beschlussDokumentId: g.beschlussDokumentId, - beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, - dokumente, - })) - - return ( -
- - - {anfrage && ( - - )} - {schreiben && ( - - )} - {schreiben && } - - } - /> - - {/* --- Kopf der Akte ---------------------------------------------- */} - - -
-

Status

-

{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}

- {schreiben && ( - - )} -
- -
-

Gegenstelle

-

{vorgang.gegenstelle ?? '–'}

- {vorgang.letzterKontaktAm && ( -

-

- )} -
- -
-

Aktenzeichen

-

{vorgang.aktenzeichen ?? '–'}

-
- - {/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse - stehen dabei - sonst schlaegt man sie doch wieder nach. */} -
-

Ansprechpartner

- {vorgang.ansprechpartner ? ( - <> -

- - {vorgang.ansprechpartner.name} - -

- {vorgang.ansprechpartner.organisation && ( -

- {vorgang.ansprechpartner.organisation} -

- )} - {(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && ( -

- {[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email] - .filter(Boolean) - .join(' · ')} -

- )} - - ) : ( -

- Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“. -

- )} -
- -
-

Nächster Schritt

-

- {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} -

-
- -
-

Einordnung

-
- {KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]} - - {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} - - -
-
-
-
- - {/* --- Aufgaben ----------------------------------------------------- */} -
-

- Aufgaben -

- -
- - {/* --- Genehmigungen ------------------------------------------------ */} - {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( -
-

- Genehmigung des Betreuungsgerichts -

- - {genehmigungen.map((genehmigung) => ( - - ))} - - {schreiben && genehmigungen.length === 0 && ( - - )} -
- )} - - {/* --- Zeitstrahl --------------------------------------------------- */} -
-

- Zeitstrahl -

-

- Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste - zuerst. -

- -
- - {/* --- Bearbeiten --------------------------------------------------- */} - {schreiben && ( -
- - - Angaben bearbeiten - - - - - -
- )} -
- ) -} +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, FileDown, Clock } from 'lucide-react' +import type { VorgangStatus } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' +import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' +import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' +import { + GenehmigungsKette, + type GenehmigungsAnzeige, +} from '@/components/vorgaenge/genehmigungs-kette' +import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' +import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' +import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' +import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' +import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' +import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' +import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' +import { + VORGANG_STATUS_BESCHRIFTUNG, + zeitstrahlBauen, + type ZeitstrahlStatuswechsel, +} from '@/lib/vorgaenge/zeitstrahl' +import { formatDatum } from '@/lib/utils' + +/** + * Die Fallakte. + * + * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; + * darunter die Aufgabenliste, das Genehmigungsverfahren und der + * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. + */ +export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) notFound() + + const vorgang = await db.vorgang.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, + genehmigungen: { + where: nichtGeloescht, + include: { beschlussDokument: { select: { id: true, titel: true } } }, + orderBy: { erstelltAm: 'asc' }, + }, + fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, + buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + verknuepfungen: { include: { dokument: true } }, + ansprechpartner: true, + }, + }) + if (!vorgang) notFound() + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') + const schreiben = darfSchreiben('VORGAENGE') + + 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 -------------------------------------- + // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die + // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. + const protokolle = await db.auditLog.findMany({ + where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, + orderBy: { zeitpunkt: 'asc' }, + take: 200, + }) + + const statuswechsel: ZeitstrahlStatuswechsel[] = [] + for (const eintrag of protokolle) { + const roh = entschluesselnWennMoeglich( + eintrag.aenderungVerschluesselt, + 'aenderungVerschluesselt', + ) + if (!roh) continue + try { + const gelesen = JSON.parse(roh) as Record + const status = gelesen.status + if (!status || typeof status.neu !== 'string') continue + statuswechsel.push({ + id: eintrag.id, + zeitpunkt: eintrag.zeitpunkt, + von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, + nach: status.neu as VorgangStatus, + wer: eintrag.userName, + }) + } catch { + // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. + continue + } + } + + // --- Zeitstrahl ----------------------------------------------------------- + const zeitstrahl = zeitstrahlBauen({ + vorgang: { + titel: vorgang.titel, + erstelltAm: vorgang.erstelltAm, + erledigtAm: vorgang.erledigtAm, + }, + dokumente: vorgang.verknuepfungen + .map((v) => v.dokument) + .filter((d) => d.geloeschtAm === null) + .map((d) => ({ + id: d.id, + titel: d.titel, + eingangAm: d.eingangAm, + versandAm: d.versandAm, + erstelltAm: d.erstelltAm, + absender: d.absender, + empfaenger: d.empfaenger, + })), + kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { + const klar = oeffnen('Kontaktprotokoll', p) + return { + id: p.id, + datum: p.datum, + art: p.art, + gespraechspartner: p.gespraechspartner, + mitBetreuter: p.mitBetreuter, + inhalt: klar.inhalt ?? null, + } + }), + fristen: vorgang.fristen.map((f) => ({ + id: f.id, + titel: f.titel, + datum: f.datum, + erledigtAm: f.erledigtAm, + ausschlussfrist: f.ausschlussfrist, + rechtsgrundlage: f.rechtsgrundlage, + })), + buchungen: vorgang.buchungen.map((b) => { + const klar = oeffnen('Buchung', b) + return { + id: b.id, + datum: b.datum, + betragCent: centZuZahl(b.betragCent) ?? 0, + richtung: b.richtung, + gegenstelle: b.gegenstelle, + verwendungszweck: klar.verwendungszweck ?? null, + } + }), + genehmigungen: vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + })), + statuswechsel, + }) + + // --- Sachstandsanfrage ---------------------------------------------------- + // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der + // Knopf nur Zierde. + const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' + const anfrage = wartetAufDritte + ? sachstandsanfrageErzeugen({ + vorgangTitel: vorgang.titel, + gegenstelle: vorgang.gegenstelle, + aktenzeichen: vorgang.aktenzeichen, + letzterKontaktAm: vorgang.letzterKontaktAm, + betreute: { + vorname: aktive.vorname, + nachname: aktive.nachname, + geburtsdatum: aktive.geburtsdatum, + }, + betreuer: { + name: benutzer.name, + aktenzeichenGericht: aktive.aktenzeichenGericht, + betreuungsgericht: aktive.betreuungsgericht, + }, + ansprechpartner: vorgang.ansprechpartner + ? { + name: vorgang.ansprechpartner.name, + anrede: vorgang.ansprechpartner.anrede, + } + : null, + }) + : null + + // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. + const dokumente = vorgang.genehmigungen.length + ? await db.dokument.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, titel: true }, + orderBy: { erstelltAm: 'desc' }, + take: 100, + }) + : [] + + const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ + id: a.id, + titel: a.titel, + beschreibung: a.beschreibung, + erledigt: a.erledigt, + faelligAm: a.faelligAm, + })) + + const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + beschlussDokumentId: g.beschlussDokumentId, + beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, + dokumente, + })) + + return ( +
+ + + {anfrage && ( + + )} + {schreiben && ( + + )} + {schreiben && } + + } + /> + + {/* --- Kopf der Akte ---------------------------------------------- */} + + +
+

Status

+

{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}

+ {schreiben && ( + + )} +
+ +
+

Gegenstelle

+

{vorgang.gegenstelle ?? '–'}

+ {vorgang.letzterKontaktAm && ( +

+

+ )} +
+ +
+

Aktenzeichen

+

{vorgang.aktenzeichen ?? '–'}

+
+ + {/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse + stehen dabei - sonst schlaegt man sie doch wieder nach. */} +
+

Ansprechpartner

+ {vorgang.ansprechpartner ? ( + <> +

+ + {vorgang.ansprechpartner.name} + +

+ {vorgang.ansprechpartner.organisation && ( +

+ {vorgang.ansprechpartner.organisation} +

+ )} + {(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && ( +

+ {[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email] + .filter(Boolean) + .join(' · ')} +

+ )} + + ) : ( +

+ Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“. +

+ )} +
+ +
+

Nächster Schritt

+

+ {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} +

+
+ +
+

Einordnung

+
+ {KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]} + + {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} + + +
+
+
+
+ + {/* --- Aufgaben ----------------------------------------------------- */} +
+

+ Aufgaben +

+ +
+ + {/* --- Genehmigungen ------------------------------------------------ */} + {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( +
+

+ Genehmigung des Betreuungsgerichts +

+ + {genehmigungen.map((genehmigung) => ( + + ))} + + {schreiben && genehmigungen.length === 0 && ( + + )} +
+ )} + + {/* --- Zeitstrahl --------------------------------------------------- */} +
+

+ Zeitstrahl +

+

+ Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste + zuerst. +

+ +
+ + {/* --- Bearbeiten --------------------------------------------------- */} + {schreiben && ( +
+ + + Angaben bearbeiten + + + + + +
+ )} +
+ ) +} diff --git a/src/components/sozialhilfe/heimkosten-formular.tsx b/src/components/sozialhilfe/heimkosten-formular.tsx index ab2e008..34c8564 100644 --- a/src/components/sozialhilfe/heimkosten-formular.tsx +++ b/src/components/sozialhilfe/heimkosten-formular.tsx @@ -1,310 +1,310 @@ -'use client' - -import { useActionState, useState } from 'react' -import { Feld, Feldgruppe } from '@/components/feld' -import { Rechtshinweis } from '@/components/rechtshinweis' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Select } from '@/components/ui/select' -import { Textarea } from '@/components/ui/textarea' -import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine' -import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten' -import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe' - -export type HeimkostenWerte = { - jahr: number - monat: number - pflegeEigenanteil: string - unterkunftVerpflegung: string - investitionskosten: string - ausbildungsumlage: string - zusatzleistungen: string - pflegekasse: string - leistungszuschlagProzent: number - leistungszuschlag: string - rente: string - sonstigeEinkuenfte: string - sozialhilfe: string - eigenmittel: string - barbetrag: string - bekleidungspauschale: string - abwesenheitstage: number - bemerkung: string -} - -/** - * Ein Monat der Gegenüberstellung. - * - * Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der - * Kosten, dann die Deckungsquellen, dann das, was der betreuten Person - * verbleiben muss. Wer von oben nach unten abtippt, findet alles an der - * Stelle, an der es auf der Rechnung steht. - */ -export function HeimkostenFormular({ - werte, - jahre, - hinweisPflegekasse, - vorschlagBarbetrag, -}: { - werte: HeimkostenWerte - jahre: number[] - /** - * Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN - * Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der - * Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert - * deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts. - */ - hinweisPflegekasse: string - vorschlagBarbetrag: { betrag: string; text: string } -}) { - const [zustand, aktion] = useActionState(heimkostenSpeichern, {}) - const [barbetrag, setBarbetrag] = useState(werte.barbetrag) - - return ( -
- - - - - Monat erfassen - - Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird - überschrieben. - - - - -
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {vorschlagBarbetrag.text}{' '} - - - } - > - setBarbetrag(e.target.value)} - /> - - - - - - - - - - - - -