From 5cc7f5c140727dda7a4c232c86ba311e5b6298bc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 22 Aug 2026 06:22:44 +0200 Subject: [PATCH] feat: Jahresbericht, Vollexport, Wissensbasis und Mitleser-Freigaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etappe 7. Die Ausgabeschicht - alles, was diese Anwendung nach aussen abgibt. Der Jahresbericht nach § 1863 BGB ist ein ENTWURF zum Ueberarbeiten, kein fertiger Text. Er zieht sich die fuenf Gliederungspunkte aus Kontaktprotokollen, Gesundheitsereignissen und Vorgangsstatus zusammen; was davor abgegeben wird, entscheidet der Betreuer. Das sagt auch die Oberflaeche. Ausgabe als PDF und als DOCX, damit sich weiterschreiben laesst. Vollexport als ZIP-Strom mit Gegendruck, damit auch grosse Bestaende ohne Speicherausbruch durchgehen: Originaldateien nach Jahren sortiert, alle Tabellen als entschluesseltes JSON, die erzeugten Berichte, ein LIESMICH und SHA-256 je Datei. Damit laesst sich die Anwendung verlassen - kein Datensilo. Der Export ist eine sensible Aktion: nur BETREUER und ADMIN, nur nach frischer Anmeldung, immer mit Audit-Eintrag. Ein Mitleser kommt nicht heran. 37 Hinweiskarten in daten/hinweise.json, Zod-geprueft und ohne Neustart pflegbar. Jede mit Rechtsgrundlage, Link und dem stehenden Vermerk, dass sie keine Rechtsberatung ersetzt. Modulweise Freigaben fuer Mitleser, mit ausdruecklicher Warnung bei Gesundheit, Finanzen und Sozialhilfe. Rollen- und Freigabeaenderungen erfordern eine frische Anmeldung und stehen im Audit-Log. Nebenbei geloest: Webpack loest .tsx vor .ts auf, TypeScript und Vitest umgekehrt. Ein Modul, das beide Endungen traegt, importiert sich unter einem der beiden Werkzeuge selbst. Deshalb heissen die Bausteine jetzt eindeutig (jahresbericht.entwurf.ts, .inhalt.tsx, .ausgabe.ts) und werden ueber eine gemeinsame Fassade weitergereicht. 41 Tests. Etappe: 7 Phase: Ausgabe --- docs/schema-wuensche/berichte.md | 143 ++ src/app/(app)/berichte/belegmappe/page.tsx | 71 + src/app/(app)/berichte/export/datei/route.ts | 128 ++ src/app/(app)/berichte/export/page.tsx | 79 + .../jahresbericht/[berichtId]/page.tsx | 106 ++ src/app/(app)/berichte/jahresbericht/page.tsx | 133 ++ src/app/(app)/berichte/page.tsx | 163 ++ .../(app)/berichte/unterlagenpaket/page.tsx | 141 ++ src/app/(app)/einstellungen/benutzer/page.tsx | 64 + .../(app)/einstellungen/freigaben/page.tsx | 100 ++ src/app/(app)/einstellungen/page.tsx | 91 ++ .../(app)/einstellungen/papierkorb/page.tsx | 69 + src/components/berichte/berichts-liste.tsx | 226 +++ src/components/berichte/export-formular.tsx | 121 ++ src/components/berichte/kontext.ts | 79 + .../berichte/unterlagenpaket-formular.tsx | 137 ++ .../einstellungen/benutzer-verwaltung.tsx | 254 ++++ .../einstellungen/freigabe-formular.tsx | 208 +++ .../einstellungen/papierkorb-liste.tsx | 143 ++ src/lib/berichte/docx.ts | 426 +++--- src/lib/berichte/jahresbericht.ausgabe.ts | 134 ++ src/lib/berichte/jahresbericht.entwurf.ts | 694 +++++++++ src/lib/berichte/jahresbericht.inhalt.tsx | 89 ++ src/lib/berichte/jahresbericht.ts | 707 +-------- src/lib/berichte/jahresbericht.tsx | 251 +--- src/server/actions/berichte.ts | 1250 ++++++++-------- src/server/actions/freigaben.ts | 770 +++++----- src/server/actions/papierkorb.ts | 542 +++---- tests/berichte.test.ts | 1330 ++++++++--------- 29 files changed, 5573 insertions(+), 3076 deletions(-) create mode 100644 docs/schema-wuensche/berichte.md create mode 100644 src/app/(app)/berichte/belegmappe/page.tsx create mode 100644 src/app/(app)/berichte/export/datei/route.ts create mode 100644 src/app/(app)/berichte/export/page.tsx create mode 100644 src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx create mode 100644 src/app/(app)/berichte/jahresbericht/page.tsx create mode 100644 src/app/(app)/berichte/page.tsx create mode 100644 src/app/(app)/berichte/unterlagenpaket/page.tsx create mode 100644 src/app/(app)/einstellungen/benutzer/page.tsx create mode 100644 src/app/(app)/einstellungen/freigaben/page.tsx create mode 100644 src/app/(app)/einstellungen/page.tsx create mode 100644 src/app/(app)/einstellungen/papierkorb/page.tsx create mode 100644 src/components/berichte/berichts-liste.tsx create mode 100644 src/components/berichte/export-formular.tsx create mode 100644 src/components/berichte/kontext.ts create mode 100644 src/components/berichte/unterlagenpaket-formular.tsx create mode 100644 src/components/einstellungen/benutzer-verwaltung.tsx create mode 100644 src/components/einstellungen/freigabe-formular.tsx create mode 100644 src/components/einstellungen/papierkorb-liste.tsx create mode 100644 src/lib/berichte/jahresbericht.ausgabe.ts create mode 100644 src/lib/berichte/jahresbericht.entwurf.ts create mode 100644 src/lib/berichte/jahresbericht.inhalt.tsx diff --git a/docs/schema-wuensche/berichte.md b/docs/schema-wuensche/berichte.md new file mode 100644 index 0000000..07b3dc2 --- /dev/null +++ b/docs/schema-wuensche/berichte.md @@ -0,0 +1,143 @@ +# Schema-Wünsche aus der Ausgabeschicht (Etappe 7) + +`prisma/schema.prisma` wurde nicht angefasst. Was beim Bauen der Berichte, +des Exports und der Freigaben gefehlt hat, steht hier – mit Begründung und +mit dem Behelf, der jetzt an seiner Stelle steht. + +--- + +## 1. Mehrere Fassungen je Bericht + +**Jetzt:** `Bericht` trägt genau ein `objektSchluessel` / `dateiname` / +`pruefsumme`. Wird das PDF nach einer Rückfrage des Gerichts neu erzeugt, +überschreibt es die Angaben der Fassung, die bereits vorgelegt wurde. + +**Warum das stört:** Die Leitfrage der Tabelle ist „was habe ich wann in +welcher Fassung abgegeben?“. Genau die lässt sich nach dem zweiten +Erzeugen nicht mehr beantworten. Fragt der Rechtspfleger in zwei Jahren +nach, welcher Text ihm vorlag, steht der neue da. + +**Wunsch:** + +```prisma +model Berichtsfassung { + id String @id @default(cuid()) + berichtId String + bericht Bericht @relation(fields: [berichtId], references: [id], onDelete: Cascade) + nummer Int // 1, 2, 3 … + format String // "pdf" | "docx" + objektSchluessel String + dateiname String + pruefsumme String + erstelltAm DateTime @default(now()) + abgegebenAm DateTime? @db.Date + abgegebenAn String? + + @@unique([berichtId, nummer, format]) +} +``` + +**Behelf bis dahin:** Die PDF-Fassung wird am `Bericht` vermerkt, die +DOCX-Arbeitsfassung bewusst *nicht* – sonst verdrängte sie die abgegebene +Fassung. Die Word-Datei liegt im Objektspeicher und taucht im Vollexport +auf, ist aber über die Oberfläche nur bis zum nächsten Erzeugen erreichbar. + +--- + +## 2. Zusammenhang zwischen Unterlagenpaket und Nachforderung + +**Jetzt:** Beim Erzeugen eines Pakets werden die beigefügten +`Nachforderung`-Zeilen auf `UEBERSANDT` gesetzt und bekommen ein +`uebersandtAm`. In welchem Paket sie mitgegangen sind, steht nirgends. + +**Warum das stört:** Der Träger fragt regelmäßig nach einer einzelnen +Position („zu Nr. 7 fehlt uns noch etwas“). Ohne Rückbezug muss man raten, +welches Anschreiben Nr. 7 enthielt. + +**Wunsch:** `Nachforderung.uebersandtMitBerichtId String?` mit Relation auf +`Bericht`. + +**Behelf:** Das Anschreiben nennt die laufende Nummer, und der +`Bericht`-Datensatz trägt Datum und Positionsanzahl im Titel. + +--- + +## 3. Betreuungsjahr am Bericht + +**Wunsch:** `Bericht.betreuungsjahr Int?` – die laufende Nummer des +Betreuungsjahres, aus dem der Jahresbericht stammt. + +**Warum:** Das Betreuungsjahr läuft ab dem Tag der Bestellung. Zeiträume +allein sind für die Dublettenprüfung unhandlich, sobald jemand den +Vorschlag im Formular um einen Tag verschiebt. + +**Behelf:** Geprüft wird auf `art + zeitraumVon + zeitraumBis`. Ein um +einen Tag verschobener Zeitraum erzeugt deshalb einen zweiten Entwurf. + +--- + +## 4. Index für die Dublettenprüfung + +**Wunsch:** `@@index([betreuteId, art, zeitraumVon, zeitraumBis])` auf +`Bericht`. Vorhanden ist nur `@@index([betreuteId, art])`. + +--- + +## 5. Aufbewahrungsfrist am Papierkorb + +**Wunsch:** `geloeschtVonId String?` auf allen Modellen mit `geloeschtAm`. + +**Warum:** Das Audit-Log weiß, wer gelöscht hat, aber die Papierkorbliste +müsste dafür je Zeile einen Log-Eintrag suchen. Für die Frage „wer hat das +weggeräumt?“ direkt an der Zeile wäre ein Feld billiger. + +--- + +# Entscheidungen, die keine Schemaänderung brauchen + +## Zwei gleichnamige Dateien: `jahresbericht.ts` und `jahresbericht.tsx` + +Der Bauplan verlangt beide. Beide nebeneinander sind aber eine Falle: Beim +Import ohne Endung probiert Webpack `.tsx` vor `.ts`, TypeScript und Vitest +umgekehrt. Derselbe Importpfad wäre je nach Werkzeug eine andere Datei – +und ein `export * from './jahresbericht'` innerhalb der `.tsx` würde unter +Webpack die Datei sich selbst importieren. + +Gelöst über eindeutige Namen für die Bausteine: + +| Datei | Inhalt | +|---|---| +| `jahresbericht.entwurf.ts` | Textbausteine, rein, ohne Datenbank – das, was getestet wird | +| `jahresbericht.inhalt.tsx` | Satz für das PDF (React) | +| `jahresbericht.ausgabe.ts` | Daten laden, PDF erzeugen | +| `jahresbericht.ts` | reicht alle drei weiter | +| `jahresbericht.tsx` | reicht alle drei weiter (identisch) | + +Damit bekommt jeder Aufrufer von `@/lib/berichte/jahresbericht` dieselbe +Schnittstelle, gleich welches Werkzeug auflöst. Wer nur die Textbausteine +braucht – die Tests, die DOCX-Ausgabe – importiert direkt +`@/lib/berichte/jahresbericht.entwurf` und spart sich Datenbank und +PDF-Bibliothek. + +**Alternative, falls das später stört:** `allowImportingTsExtensions: true` +in der `tsconfig.json` (mit `noEmit: true` unbedenklich, das ist bereits +gesetzt). Dann ließen sich die Endungen ausschreiben und die beiden +weiterreichenden Dateien entfielen. + +## Vollexport ohne Serverkopie + +Das ZIP wird als Strom ausgeliefert und **nicht** im Objektspeicher +abgelegt. Ein `Bericht`-Datensatz der Art `VOLLEXPORT` hält nur fest, dass +und wann exportiert wurde; `objektSchluessel` bleibt leer. + +Grund: Eine unverschlüsselte Vollkopie des gesamten Bestands dauerhaft auf +dem Server liegen zu lassen, wäre die größte Angriffsfläche der ganzen +Anwendung – und niemand bräuchte sie ein zweites Mal. + +## Landesrecht ohne Bundesgesetz-Link + +Zwei Hinweiskarten betreffen bayerisches Landesrecht bzw. den +Rundfunkbeitragsstaatsvertrag (`landespflegegeld`, +`rundfunkbeitragsbefreiung`). Beides steht nicht auf +`gesetze-im-internet.de`; die Karten verlinken deshalb auf +`gesetze-bayern.de`. Das Schema verlangt nur einen `https`-Link. diff --git a/src/app/(app)/berichte/belegmappe/page.tsx b/src/app/(app)/berichte/belegmappe/page.tsx new file mode 100644 index 0000000..38b7c92 --- /dev/null +++ b/src/app/(app)/berichte/belegmappe/page.tsx @@ -0,0 +1,71 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { BelegmappeFormular } from '@/components/berichte/belegmappe-formular' +import { berichtskontext } from '@/components/berichte/kontext' +import { HinweisKarten } from '@/components/hinweise/HinweisKarten' +import { db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag, heuteAlsTag, tag } from '@/lib/fristen/datum' + +export const metadata = { title: 'Belegmappe' } + +/** + * Belegmappe zusammenstellen. + * + * Vorbelegt ist das vergangene Kalenderjahr - der Zeitraum, fuer den das + * Gericht die Belege sehen will. Wer eine Mappe zu einer einzelnen Sache + * braucht (weil der Bezirk nachfragt), waehlt stattdessen den Vorgang. + */ +export default async function BelegmappeSeite() { + const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') + + const heute = heuteAlsTag() + const vorjahr = heute.getUTCFullYear() - 1 + + const vorgaenge = await db.vorgang.findMany({ + where: { betreuteId, ...nichtGeloescht }, + select: { id: true, titel: true, status: true }, + orderBy: [{ status: 'asc' }, { erstelltAm: 'desc' }], + take: 200, + }) + + return ( +
+ + + + Belege gehören zur Rechnungslegung + + Auch als befreiter Betreuer, der nur eine Vermögensübersicht schuldet, solltest du die + Belege sammeln und geordnet halten: Das Gericht kann sie anfordern, und am Ende der + Betreuung ist Schlussrechnung zu legen. Dann liegt die Beweislast bei dir. + + + + {darfBerichteSchreiben ? ( + ({ + id: vorgang.id, + titel: + vorgang.status === 'ERLEDIGT' ? `${vorgang.titel} (erledigt)` : vorgang.titel, + }))} + vorschlagVon={alsIsoTag(tag(vorjahr, 1, 1))} + vorschlagBis={alsIsoTag(tag(vorjahr, 12, 31))} + /> + ) : ( +

+ Du hast nur Leserechte für die Berichte und kannst deshalb keine Belegmappe erzeugen. +

+ )} + + + + +
+ ) +} diff --git a/src/app/(app)/berichte/export/datei/route.ts b/src/app/(app)/berichte/export/datei/route.ts new file mode 100644 index 0000000..9ad88f7 --- /dev/null +++ b/src/app/(app)/berichte/export/datei/route.ts @@ -0,0 +1,128 @@ +import { Readable } from 'node:stream' +import type { NextRequest } from 'next/server' +import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung' +import { darf } from '@/lib/auth/rollen' +import { db, nichtGeloescht } from '@/lib/db' +import { protokollieren } from '@/lib/audit' +import { vollexportStroemen } from '@/lib/berichte/vollexport' + +/** + * Auslieferung des Vollexports. + * + * Warum eine eigene Route und keine Server Action? Weil das Archiv als + * Strom entsteht: Waehrend der Browser die ersten Bytes empfaengt, wird + * die naechste Datei noch gelesen. Eine Server Action kann keinen Strom + * zurueckgeben - sie muesste das ganze ZIP erst im Arbeitsspeicher + * aufbauen, und bei ein paar hundert eingescannten Bescheiden ist das der + * Punkt, an dem der Server umfaellt. + * + * Die Pruefung ist hier vollstaendig, nicht abgekuerzt: angemeldet, + * Zugriff auf genau diese betreute Person, Rolle BETREUER oder ADMIN, + * frische Anmeldung. Ein Mitleser kommt hier nie durch - auch nicht mit + * allen Modulfreigaben. + */ + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function abgelehnt(nachricht: string, status: number): Response { + return new Response(nachricht, { + status, + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' }, + }) +} + +export async function GET(anfrage: NextRequest): Promise { + const benutzer = await erfordereBenutzer('/berichte/export') + + const gewuenscht = anfrage.nextUrl.searchParams.get('betreute') + const aktive = await aktiveBetreute(benutzer) + const betreuteId = gewuenscht ?? aktive?.id + if (!betreuteId) return abgelehnt('Es ist keine betreute Person ausgewählt.', 400) + + const betreute = await db.betreute.findFirst({ + where: { id: betreuteId, ...nichtGeloescht }, + select: { id: true, vorname: true, nachname: true }, + }) + if (!betreute) return abgelehnt('Diese betreute Person gibt es nicht.', 404) + + const eintrag = await db.betreuungsZugang.findUnique({ + where: { userId_betreuteId: { userId: benutzer.id, betreuteId } }, + select: { rolle: true, freigegebeneModule: true }, + }) + const zugang = eintrag + ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule } + : benutzer.rolle === 'ADMIN' + ? { rolle: 'ADMIN' as const, freigegebeneModule: [] } + : null + + if (!darf(zugang, 'BERICHTE', 'schreiben')) { + return abgelehnt('Für diesen Bereich fehlt dir die Berechtigung.', 403) + } + if (zugang!.rolle !== 'BETREUER' && zugang!.rolle !== 'ADMIN') { + return abgelehnt('Einen Vollexport darf nur der Betreuer oder ein Administrator erzeugen.', 403) + } + if (!(await reauthGueltig(benutzer.id))) { + return abgelehnt( + 'Der Export muss zuerst freigeschaltet werden. Geh zurück auf die Exportseite und ' + + 'bestätige deine Anmeldung erneut.', + 403, + ) + } + + const ja = (name: string) => anfrage.nextUrl.searchParams.get(name) !== 'nein' + + const { dateiname, strom } = await vollexportStroemen({ + betreuteId, + mitDokumenten: ja('dokumente'), + mitAuditLog: ja('audit'), + mitBerichten: ja('berichte'), + erstelltVon: benutzer.name, + }) + + // Der Eintrag entsteht, bevor das erste Byte fliesst: Ein abgebrochener + // Download aendert nichts daran, dass der Bestand das Haus verlassen hat. + const vermerk = await db.bericht.create({ + data: { + betreuteId, + art: 'VOLLEXPORT', + titel: 'Vollexport aller Daten', + stichtag: new Date(), + dateiname, + erstelltVonId: benutzer.id, + }, + }) + + await protokollieren({ + aktion: 'EXPORTIERT', + entitaet: 'Betreute', + entitaetId: betreuteId, + bezeichnung: `Vollexport ${dateiname}`, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + aenderung: { + umfang: { + alt: null, + neu: { + dokumente: ja('dokumente'), + auditLog: ja('audit'), + berichte: ja('berichte'), + vermerk: vermerk.id, + }, + }, + }, + }) + + const koerper = Readable.toWeb(strom) as ReadableStream + + return new Response(koerper, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${dateiname}"`, + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + }, + }) +} diff --git a/src/app/(app)/berichte/export/page.tsx b/src/app/(app)/berichte/export/page.tsx new file mode 100644 index 0000000..a1e6bc6 --- /dev/null +++ b/src/app/(app)/berichte/export/page.tsx @@ -0,0 +1,79 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { ExportFormular } from '@/components/berichte/export-formular' +import { berichtskontext } from '@/components/berichte/kontext' +import { db, nichtGeloescht } from '@/lib/db' +import { formatDatumZeit } from '@/lib/utils' + +export const metadata = { title: 'Vollexport' } + +/** + * Vollexport aller Daten einer betreuten Person. + * + * Die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine Daten + * gehoeren dir, und du kommst jederzeit ohne sie an alles heran. Zugleich + * die heikelste Aktion im ganzen Programm - deshalb frische Anmeldung, + * Rolle BETREUER oder ADMIN, und jeder Export steht im Audit-Log. + */ +export default async function ExportSeite() { + const { betreuteId, betreute, zugang } = await berichtskontext('lesen') + + const darfExportieren = zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN' + + const [anzahlDokumente, anzahlBerichte, letzterExport] = await Promise.all([ + db.dokument.count({ where: { betreuteId, objektSchluessel: { not: null }, ...nichtGeloescht } }), + db.bericht.count({ where: { betreuteId, objektSchluessel: { not: null }, ...nichtGeloescht } }), + db.bericht.findFirst({ + where: { betreuteId, art: 'VOLLEXPORT' }, + orderBy: { erstelltAm: 'desc' }, + include: { erstelltVon: { select: { name: true } } }, + }), + ]) + + return ( +
+ + +
+
+
Dateien im Archiv
+
{anzahlDokumente}
+
+
+
Erzeugte Berichte
+
{anzahlBerichte}
+
+
+
Letzter Export
+
+ {letzterExport + ? `${formatDatumZeit(letzterExport.erstelltAm)}${ + letzterExport.erstelltVon ? ` · ${letzterExport.erstelltVon.name}` : '' + }` + : 'noch keiner'} +
+
+
+ + {darfExportieren ? ( + + ) : ( + + Für den Export fehlt dir die Berechtigung + + Ein Vollexport enthält den gesamten Bestand im Klartext. Erzeugen darf ihn deshalb nur + der Betreuer dieser Person oder ein Administrator – ein Mitleser nie, auch nicht mit + allen Modulfreigaben. + + + )} + + +
+ ) +} diff --git a/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx b/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx new file mode 100644 index 0000000..b9ff684 --- /dev/null +++ b/src/app/(app)/berichte/jahresbericht/[berichtId]/page.tsx @@ -0,0 +1,106 @@ +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { Download } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Button } from '@/components/ui/button' +import { EntwurfEditor } from '@/components/berichte/entwurf-editor' +import { berichtskontext } from '@/components/berichte/kontext' +import { HinweisKarten } from '@/components/hinweise/HinweisKarten' +import { db, nichtGeloescht } from '@/lib/db' +import { entschluesselnWennMoeglich } from '@/lib/crypto/feld' +import { signierteUrl } from '@/lib/speicher' +import { ERGAENZEN, offeneErgaenzungen } from '@/lib/berichte/jahresbericht.entwurf' +import { formatDatum } from '@/lib/utils' + +export const metadata = { title: 'Jahresbericht bearbeiten' } + +/** + * Der Arbeitsplatz am Jahresbericht. + * + * Hier wird geschrieben. Der Entwurf steht im Textfeld, Zwischenspeichern + * geht jederzeit, und erst wenn der Text steht, entsteht daraus ein PDF + * (die abzugebende Fassung) oder eine Word-Datei (die Arbeitsfassung). + */ +export default async function JahresberichtBearbeitenSeite({ + params, +}: { + params: Promise<{ berichtId: string }> +}) { + const { berichtId } = await params + const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') + + const bericht = await db.bericht.findFirst({ + where: { id: berichtId, betreuteId, art: 'JAHRESBERICHT', ...nichtGeloescht }, + }) + if (!bericht) notFound() + + const entwurf = + entschluesselnWennMoeglich(bericht.entwurfVerschluesselt, 'entwurfVerschluesselt') ?? '' + + const downloadUrl = bericht.objektSchluessel + ? await signierteUrl(bericht.objektSchluessel).catch(() => null) + : null + + return ( +
+ + + + + ) : undefined + } + /> + + {darfBerichteSchreiben ? ( + + ) : ( +
+

+ Du hast nur Leserechte für die Berichte. Der Entwurf steht hier zum Mitlesen. +

+
+            {entwurf || 'Es ist noch kein Text hinterlegt.'}
+          
+
+ )} + + {bericht.abgegebenAm && ( +

+ Abgegeben am {formatDatum(bericht.abgegebenAm)} + {bericht.abgegebenAn ? ` an ${bericht.abgegebenAn}` : ''}. Eine Änderung am Text ändert + nichts an der bereits vorgelegten Fassung – erzeuge dafür ein neues PDF und vermerke die + Abgabe erneut in der{' '} + + Übersicht + + . +

+ )} + + + + +
+ ) +} diff --git a/src/app/(app)/berichte/jahresbericht/page.tsx b/src/app/(app)/berichte/jahresbericht/page.tsx new file mode 100644 index 0000000..590397e --- /dev/null +++ b/src/app/(app)/berichte/jahresbericht/page.tsx @@ -0,0 +1,133 @@ +import Link from 'next/link' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { JahresberichtFormular } from '@/components/berichte/jahresbericht-formular' +import { berichtskontext } from '@/components/berichte/kontext' +import { HinweisKarten } from '@/components/hinweise/HinweisKarten' +import { db, nichtGeloescht } from '@/lib/db' +import { + betreuungsjahrBeginn, + betreuungsjahrEnde, + laufendesBetreuungsjahr, +} from '@/lib/fristen/pflichtfristen' +import { alsIsoTag, heuteAlsTag, plusTage } from '@/lib/fristen/datum' +import { formatDatum } from '@/lib/utils' + +export const metadata = { title: 'Jahresbericht' } + +/** + * Einstieg in den Jahresbericht: Zeitraum waehlen, Entwurf erzeugen. + * + * Vorbelegt ist das laufende Betreuungsjahr. Das ist der Punkt, an dem die + * meisten Betreuer falsch liegen: Das Betreuungsjahr laeuft ab dem Tag der + * Bestellung, nicht ab dem 1. Januar - und danach richtet sich auch, wann + * der Bericht faellig ist. + */ +export default async function JahresberichtSeite() { + const { betreute, betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') + + const heute = heuteAlsTag() + let von = heute + let bis = heute + + if (betreute.bestellungAm) { + const nummer = laufendesBetreuungsjahr(betreute.bestellungAm, heute) + von = betreuungsjahrBeginn(betreute.bestellungAm, nummer) + bis = betreuungsjahrEnde(betreute.bestellungAm, nummer) + } else { + von = plusTage(heute, -365) + } + + const vorhandene = await db.bericht.findMany({ + where: { betreuteId, art: 'JAHRESBERICHT', ...nichtGeloescht }, + orderBy: { zeitraumBis: 'desc' }, + take: 20, + }) + + return ( +
+ + + + Die Anwendung schreibt einen Entwurf, nicht den Bericht + + Aus den erfassten Kontakten, Gesundheitsereignissen und Vorgängen entsteht ein + Textvorschlag mit allen fünf Punkten, die § 1863 Abs. 3 BGB verlangt. Diesen Text + überarbeitest du anschließend im Browser – erst danach wird daraus ein PDF. Nichts wird + von allein abgegeben. + + + + {betreute.bestellungAm ? ( +

+ Bestellung am {formatDatum(betreute.bestellungAm)}. Das laufende Betreuungsjahr geht vom{' '} + {formatDatum(von)} bis zum {formatDatum(bis)}. +

+ ) : ( + + Kein Bestellungsdatum hinterlegt + + Ohne den Tag deiner Bestellung lässt sich das Betreuungsjahr nicht berechnen; der + Vorschlag unten ist deshalb nur das letzte Jahr ab heute. Trag das Datum in den{' '} + + Stammdaten + {' '} + nach – daran hängen auch die Pflichtfristen. + + + )} + + {darfBerichteSchreiben ? ( + + ) : ( +

+ Du hast nur Leserechte für die Berichte und kannst deshalb keinen Entwurf erzeugen. +

+ )} + + {vorhandene.length > 0 && ( +
+

Bisherige Jahresberichte

+
    + {vorhandene.map((bericht) => ( +
  • + + + {bericht.titel} + + + {formatDatum(bericht.zeitraumVon)} – {formatDatum(bericht.zeitraumBis)} + + + {bericht.abgegebenAm ? ( + abgegeben am {formatDatum(bericht.abgegebenAm)} + ) : bericht.objektSchluessel ? ( + PDF erzeugt, noch nicht abgegeben + ) : ( + Entwurf in Arbeit + )} +
  • + ))} +
+
+ )} + + + + +
+ ) +} diff --git a/src/app/(app)/berichte/page.tsx b/src/app/(app)/berichte/page.tsx new file mode 100644 index 0000000..d073b2f --- /dev/null +++ b/src/app/(app)/berichte/page.tsx @@ -0,0 +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

+ +
+ + + + +
+ ) +} diff --git a/src/app/(app)/berichte/unterlagenpaket/page.tsx b/src/app/(app)/berichte/unterlagenpaket/page.tsx new file mode 100644 index 0000000..6500c5d --- /dev/null +++ b/src/app/(app)/berichte/unterlagenpaket/page.tsx @@ -0,0 +1,141 @@ +import Link from 'next/link' +import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { UnterlagenpaketFormular } from '@/components/berichte/unterlagenpaket-formular' +import { berichtskontext } from '@/components/berichte/kontext' +import { HinweisKarten } from '@/components/hinweise/HinweisKarten' +import { erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' + +export const metadata = { title: 'Unterlagen für den Träger' } + +/** + * Das Unterlagenpaket fuer den Sozialhilfetraeger. + * + * Ausgangspunkt ist der Nachforderungs-Tracker: Was der Bezirk angefordert + * hat, steht dort als eigene Zeile. Hier wird ausgewaehlt, was in dieses + * Paket kommt - auch das, was es nicht gibt, denn eine unbeantwortete + * Position gilt beim Traeger als fehlende Mitwirkung. + */ +export default async function UnterlagenpaketSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') + // Die Unterlagen stammen aus der Sozialhilfeakte - auch dort muss gelesen + // werden duerfen, sonst umginge diese Seite die Modulfreigabe. + await erfordereZugang(betreuteId, 'SOZIALHILFE', 'lesen') + + const parameter = await searchParams + const gewaehlterAntrag = Array.isArray(parameter.antrag) ? parameter.antrag[0] : parameter.antrag + + const antraege = await db.sozialhilfeAntrag.findMany({ + where: { betreuteId, ...nichtGeloescht }, + orderBy: { erstelltAm: 'desc' }, + include: { + nachforderungen: { + where: nichtGeloescht, + orderBy: [{ reihenfolge: 'asc' }, { bezeichnung: 'asc' }], + include: { verknuepfungen: { select: { dokumentId: true } } }, + }, + }, + }) + + const antrag = antraege.find((a) => a.id === gewaehlterAntrag) ?? antraege[0] + + if (!antrag) { + return ( +
+ + + Zur Sozialhilfe + + } + /> +
+ ) + } + + const nachforderungen = antrag.nachforderungen.map((eintrag) => ({ + id: eintrag.id, + bezeichnung: eintrag.bezeichnung, + status: eintrag.status, + fristBis: eintrag.fristBis ? eintrag.fristBis.toISOString() : null, + begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden, + anzahlDokumente: eintrag.verknuepfungen.length, + })) + + return ( +
+ + + {antraege.length > 1 && ( + + )} + + + Auch fehlende Unterlagen gehören ins Paket + + Zu jeder Position, die du mitschickst, aber nicht belegen kannst, erscheint im Anschreiben + eine Erklärung. Das ist wichtig: Eine Position, zu der der Träger nichts hört, gilt als + fehlende Mitwirkung – und das kann die Leistung kosten. + + + + {nachforderungen.length === 0 ? ( + + Zum Nachforderungs-Tracker + + } + /> + ) : darfBerichteSchreiben ? ( + + ) : ( +

+ Du hast nur Leserechte für die Berichte und kannst deshalb kein Paket erzeugen. +

+ )} + + + + +
+ ) +} diff --git a/src/app/(app)/einstellungen/benutzer/page.tsx b/src/app/(app)/einstellungen/benutzer/page.tsx new file mode 100644 index 0000000..644128b --- /dev/null +++ b/src/app/(app)/einstellungen/benutzer/page.tsx @@ -0,0 +1,64 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { BenutzerVerwaltung, type BenutzerZeile } from '@/components/einstellungen/benutzer-verwaltung' +import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' + +export const metadata = { title: 'Benutzer' } + +/** + * Benutzerverwaltung - Administratoren vorbehalten. + * + * Die Seite prueft die Rolle selbst; jede einzelne Aktion prueft sie + * serverseitig noch einmal und verlangt zusaetzlich eine frische + * Anmeldung. Nur im UI auszublenden waere kein Schutz. + */ +export default async function BenutzerSeite() { + const angemeldet = await erfordereBenutzer('/einstellungen/benutzer') + + if (angemeldet.rolle !== 'ADMIN') { + return ( +
+ + + Dieser Bereich ist Administratoren vorbehalten + + Wer Benutzer anlegt und Rollen vergibt, entscheidet darüber, wer die Daten eines + kranken Menschen sehen darf. Diese Entscheidung liegt bewusst an einer Stelle. + + +
+ ) + } + + const benutzer = await db.user.findMany({ + where: nichtGeloescht, + orderBy: [{ aktiv: 'desc' }, { name: 'asc' }], + }) + + const zeilen: BenutzerZeile[] = benutzer.map((eintrag) => ({ + id: eintrag.id, + name: eintrag.name, + email: eintrag.email, + rolle: eintrag.rolle, + aktiv: eintrag.aktiv, + zweiterFaktor: Boolean(eintrag.totpAktiviertAm), + letzteAnmeldung: eintrag.letzteAnmeldung ? eintrag.letzteAnmeldung.toISOString() : null, + istIchSelbst: eintrag.id === angemeldet.id, + })) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/einstellungen/freigaben/page.tsx b/src/app/(app)/einstellungen/freigaben/page.tsx new file mode 100644 index 0000000..0f48f2d --- /dev/null +++ b/src/app/(app)/einstellungen/freigaben/page.tsx @@ -0,0 +1,100 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + FreigabeFormular, + type ZugangsZeile, +} from '@/components/einstellungen/freigabe-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { darfFreigabenVergeben } from '@/lib/validierung/freigaben' +import { db, nichtGeloescht } from '@/lib/db' + +export const metadata = { title: 'Freigaben' } + +/** + * Wer darf welche Bereiche der Akte lesen? + * + * Die Freigabe gilt immer fuer genau eine betreute Person und genau einen + * Benutzer. Das ist Absicht: "Meine Schwester darf die Fristen sehen, aber + * nicht die Diagnosen" muss sich abbilden lassen, ohne dass daraus ein + * Zugriff auf eine zweite Akte wird. + */ +export default async function FreigabenSeite() { + const angemeldet = await erfordereBenutzer('/einstellungen/freigaben') + const gewaehlt = await aktiveBetreute(angemeldet) + if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.') + + const { betreute, zugang } = await erfordereZugang(gewaehlt.id, 'STAMMDATEN', 'lesen') + const betreuteName = `${betreute.vorname} ${betreute.nachname}` + + if (!darfFreigabenVergeben(zugang)) { + return ( +
+ + + Freigaben vergibt nur der Betreuer + + Als Mitleser kannst du keine Zugriffe verändern – auch nicht deine eigenen. Sonst + könnte sich jeder Mitleser selbst mehr Rechte geben, als ihm eingeräumt wurden. + + +
+ ) + } + + const [benutzer, zugaenge] = await Promise.all([ + db.user.findMany({ + where: { aktiv: true, ...nichtGeloescht }, + orderBy: { name: 'asc' }, + }), + db.betreuungsZugang.findMany({ where: { betreuteId: betreute.id } }), + ]) + + const zeilen: ZugangsZeile[] = benutzer.map((eintrag) => { + const vorhandener = zugaenge.find((z) => z.userId === eintrag.id) + return { + userId: eintrag.id, + name: eintrag.name, + email: eintrag.email, + rolle: vorhandener?.rolle ?? (eintrag.rolle === 'MITLESER' ? 'MITLESER' : 'BETREUER'), + freigegebeneModule: vorhandener?.freigegebeneModule ?? [], + hatZugang: Boolean(vorhandener), + istIchSelbst: eintrag.id === angemeldet.id, + } + }) + + return ( +
+ + + + Freigeben heißt: über die Privatsphäre eines anderen entscheiden + + Was hier freigegeben wird, betrifft nicht deine Daten, sondern die von {betreuteName}. + Maßstab ist ihr Wille (§ 1821 BGB), nicht die Bequemlichkeit der Familie. Im Zweifel + weniger freigeben – nachträglich erweitern geht jederzeit. + + + +
+ {zeilen.map((zeile) => ( + + ))} +
+ + +
+ ) +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx new file mode 100644 index 0000000..4ff7acf --- /dev/null +++ b/src/app/(app)/einstellungen/page.tsx @@ -0,0 +1,91 @@ +import Link from 'next/link' +import { KeyRound, Trash2, Users } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen' + +export const metadata = { title: 'Einstellungen' } + +/** + * Einstiegsseite der Einstellungen. + * + * Hier liegt alles, was nicht zur Akte gehoert, sondern zur Anwendung + * selbst: wer sie benutzen darf, wer was davon sehen darf, und was + * geloescht, aber noch nicht weg ist. + */ +export default async function EinstellungenSeite() { + const benutzer = await erfordereBenutzer('/einstellungen') + const istAdmin = benutzer.rolle === 'ADMIN' + + return ( +
+ + Angemeldet als {benutzer.name}{' '} + {ROLLEN_BESCHRIFTUNG[benutzer.rolle]} + + } + /> + +
+ {istAdmin && ( + + + + + )} + + + + + + + + + + +
+
+ ) +} diff --git a/src/app/(app)/einstellungen/papierkorb/page.tsx b/src/app/(app)/einstellungen/papierkorb/page.tsx new file mode 100644 index 0000000..95d03c0 --- /dev/null +++ b/src/app/(app)/einstellungen/papierkorb/page.tsx @@ -0,0 +1,69 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + PapierkorbListe, + type PapierkorbGruppe, +} from '@/components/einstellungen/papierkorb-liste' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { papierkorbLaden } from '@/server/actions/papierkorb' +import { PAPIERKORB_TAGE } from '@/lib/db' + +export const metadata = { title: 'Papierkorb' } + +/** + * Was in den letzten 30 Tagen geloescht wurde. + * + * Ein Mitleser sieht hier nur, was ihm auch sonst freigegeben ist - der + * Papierkorb ist keine Hintertuer an der Modulfreigabe vorbei. + */ +export default async function PapierkorbSeite() { + const angemeldet = await erfordereBenutzer('/einstellungen/papierkorb') + const gewaehlt = await aktiveBetreute(angemeldet) + if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.') + + const { betreute, zugang } = await erfordereZugang(gewaehlt.id, 'STAMMDATEN', 'lesen') + const darfSchreiben = zugang.rolle !== 'MITLESER' + + const roh = await papierkorbLaden(betreute.id) + + const gruppen: PapierkorbGruppe[] = roh.map((gruppe) => ({ + art: gruppe.art, + bezeichnung: gruppe.bezeichnung, + eintraege: gruppe.eintraege.map((eintrag) => { + const verfaellt = new Date(eintrag.geloeschtAm) + verfaellt.setDate(verfaellt.getDate() + PAPIERKORB_TAGE) + return { + id: eintrag.id, + bezeichnung: eintrag.bezeichnung, + geloeschtAm: eintrag.geloeschtAm.toISOString(), + verfaelltAm: verfaellt.toISOString(), + } + }), + })) + + return ( +
+ + + + Endgültig löschen ist endgültig + + Beim endgültigen Löschen verschwindet auch die zugehörige Datei aus dem Speicher. Das + verlangt eine erneute Bestätigung deiner Anmeldung. Im Audit-Log bleibt festgehalten, + dass es den Datensatz gab und wer ihn entfernt hat – das ist dein Nachweis gegenüber + Gericht und möglichen Miterben. + + + + +
+ ) +} diff --git a/src/components/berichte/berichts-liste.tsx b/src/components/berichte/berichts-liste.tsx new file mode 100644 index 0000000..2d65dca --- /dev/null +++ b/src/components/berichte/berichts-liste.tsx @@ -0,0 +1,226 @@ +'use client' + +import * as React from 'react' +import Link from 'next/link' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Download, Pencil, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Feld } from '@/components/feld' +import { Leerzustand } from '@/components/seiten-kopf' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Meldung } from '@/components/berichte/meldung' +import { abgabeVermerken, berichtLoeschen, type Aktionszustand } from '@/server/actions/berichte' +import { formatDatum, formatDatumZeit } from '@/lib/utils' + +export type BerichtsZeile = { + id: string + art: string + artBeschriftung: string + titel: string + zeitraum: string | null + erstelltAm: string + erstelltVon: string | null + dateiname: string | null + /** Kurzlebige, signierte URL - serverseitig erzeugt. */ + downloadUrl: string | null + abgegebenAm: string | null + abgegebenAn: string | null + bearbeitbar: boolean +} + +function KleinerKnopf({ + children, + variante = 'ghost', +}: { + children: React.ReactNode + variante?: 'ghost' | 'outline' | 'destructive' +}) { + const { pending } = useFormStatus() + return ( + + ) +} + +/** + * Was habe ich wann in welcher Fassung abgegeben? + * + * Diese Tabelle ist die Antwort darauf. Sie ist kein Archiv um des + * Archivierens willen: Wenn das Gericht in zwei Jahren nachfragt, welche + * Fassung des Jahresberichts vorgelegen hat, steht sie hier - mit Datum, + * Empfaenger und der abgelegten Datei. + */ +export function BerichtsListe({ + zeilen, + darfSchreiben, +}: { + zeilen: BerichtsZeile[] + darfSchreiben: boolean +}) { + const [loeschstand, loeschen] = useActionState(berichtLoeschen, {}) + const [abgabestand, abgeben] = useActionState(abgabeVermerken, {}) + const [offeneAbgabe, setOffeneAbgabe] = React.useState(null) + + if (zeilen.length === 0) { + return ( + + ) + } + + return ( +
+ + +
+ + + + Bericht + Zeitraum + Erzeugt + Abgegeben + Aktionen + + + + {zeilen.map((zeile) => ( + + + + {zeile.titel} + + {zeile.artBeschriftung} + + + {zeile.zeitraum ?? '–'} + + {formatDatumZeit(zeile.erstelltAm)} + {zeile.erstelltVon && ( + {zeile.erstelltVon} + )} + + + {zeile.abgegebenAm ? ( + <> + {formatDatum(zeile.abgegebenAm)} + {zeile.abgegebenAn && ( + + an {zeile.abgegebenAn} + + )} + + ) : ( + noch nicht vermerkt + )} + + +
+ {zeile.bearbeitbar && ( + + )} + {zeile.downloadUrl && ( + + )} + {darfSchreiben && !zeile.abgegebenAm && ( + + )} + {darfSchreiben && ( +
{ + if ( + !window.confirm( + `„${zeile.titel}“ in den Papierkorb verschieben? Dort liegt er 30 Tage.`, + ) + ) { + ereignis.preventDefault() + } + }} + > + + + + + )} +
+
+
+ + {offeneAbgabe === zeile.id && ( + + +
+ + + + + + + + Vermerken + +
+
+ )} +
+ ))} +
+
+
+
+ ) +} diff --git a/src/components/berichte/export-formular.tsx b/src/components/berichte/export-formular.tsx new file mode 100644 index 0000000..2bad2e8 --- /dev/null +++ b/src/components/berichte/export-formular.tsx @@ -0,0 +1,121 @@ +'use client' + +import * as React from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Download, ShieldCheck } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Meldung } from '@/components/berichte/meldung' +import { exportVorbereiten, type Aktionszustand } from '@/server/actions/berichte' + +function Bestaetigen() { + const { pending } = useFormStatus() + return ( + + ) +} + +/** + * Vollexport anstossen. + * + * Zwei Schritte, mit Absicht: Erst wird die Anmeldung bestaetigt (die + * Aktion leitet zur Bestaetigungsseite um, wenn sie zu lange her ist), + * danach laedt der Browser das ZIP von einer eigenen Route. Ein Strom + * laesst sich aus einer Server Action nicht zurueckgeben, und ein Bestand + * aus hunderten Bescheiden soll nicht erst komplett in den Arbeitsspeicher. + */ +export function ExportFormular({ betreuteId }: { betreuteId: string }) { + const [zustand, aktion] = useActionState(exportVorbereiten, {}) + const [mitDokumenten, setMitDokumenten] = React.useState(true) + const [mitAuditLog, setMitAuditLog] = React.useState(true) + const [mitBerichten, setMitBerichten] = React.useState(true) + + const abfrage = new URLSearchParams({ + betreute: betreuteId, + dokumente: mitDokumenten ? 'ja' : 'nein', + audit: mitAuditLog ? 'ja' : 'nein', + berichte: mitBerichten ? 'ja' : 'nein', + }) + + return ( +
+ + Der Export enthält alles – unverschlüsselt + + Im Archiv stehen Diagnosen, Kontostände, Notizen und Bescheide im Klartext. Es ist die + Sicherung, mit der du auch ohne diese Anwendung weiterarbeiten kannst – und zugleich die + Datei, die niemals auf einem fremden Rechner oder in einer Cloud liegen sollte. Bewahre + sie so auf wie den Aktenordner selbst. + + + +
+ Was soll hinein? + + + + + + +
+ +
+ + + +

+ Schalte den Export zuerst frei – dabei wird deine Anmeldung erneut bestätigt. Danach + startet der Download. Bei vielen Dokumenten dauert es eine Weile, bis die erste Datei + ankommt; das Archiv wird währenddessen schon geschrieben. +

+ +
+ ) +} diff --git a/src/components/berichte/kontext.ts b/src/components/berichte/kontext.ts new file mode 100644 index 0000000..adfc16e --- /dev/null +++ b/src/components/berichte/kontext.ts @@ -0,0 +1,79 @@ +import 'server-only' +import type { BerichtArt } from '@prisma/client' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { signierteUrl } from '@/lib/speicher' +import { db, nichtGeloescht } from '@/lib/db' +import { formatDatum } from '@/lib/utils' +import type { BerichtsZeile } from '@/components/berichte/berichts-liste' + +/** + * Der gemeinsame Vorspann aller Berichtsseiten. + * + * Wie ueberall gilt: Die aktive betreute Person kommt aus dem Cookie, ob + * sie angezeigt werden darf, entscheidet erfordereZugang() gegen die + * Datenbank. Ein manipuliertes Cookie kaeme hoechstens bis hierher. + */ +export async function berichtskontext(art: 'lesen' | 'schreiben' = 'lesen') { + const benutzer = await erfordereBenutzer('/berichte') + const gewaehlt = await aktiveBetreute(benutzer) + if (!gewaehlt) throw new Error('Es ist keine betreute Person ausgewählt.') + + const kontext = await erfordereZugang(gewaehlt.id, 'BERICHTE', art) + return { + ...kontext, + betreuteId: gewaehlt.id, + darfBerichteSchreiben: kontext.darfSchreiben('BERICHTE'), + } +} + +export const BERICHTSART_BESCHRIFTUNG: Record = { + VERMOEGENSVERZEICHNIS: 'Vermögensverzeichnis (§ 1835 BGB)', + JAHRESBERICHT: 'Jahresbericht (§ 1863 BGB)', + VERMOEGENSUEBERSICHT: 'Vermögensübersicht (§ 1859 BGB)', + RECHNUNGSLEGUNG: 'Rechnungslegung (§ 1865 BGB)', + BELEGMAPPE: 'Belegmappe', + UNTERLAGENPAKET_BEZIRK: 'Unterlagenpaket für den Träger', + VOLLEXPORT: 'Vollexport', +} + +/** + * Laedt die protokollierten Berichte und erzeugt zu jeder abgelegten Datei + * eine kurzlebige, signierte Adresse. Die Adresse wird bei jedem + * Seitenaufbau neu erzeugt und laeuft nach fuenf Minuten ab - ein + * versehentlich weitergegebener Link ist dann wertlos. + */ +export async function berichtsZeilenLaden( + betreuteId: string, + darfSchreiben: boolean, +): Promise { + const berichte = await db.bericht.findMany({ + where: { betreuteId, ...nichtGeloescht }, + include: { erstelltVon: { select: { name: true } } }, + orderBy: { erstelltAm: 'desc' }, + take: 200, + }) + + return Promise.all( + berichte.map(async (bericht) => ({ + id: bericht.id, + art: bericht.art, + artBeschriftung: BERICHTSART_BESCHRIFTUNG[bericht.art], + titel: bericht.titel, + zeitraum: + bericht.zeitraumVon || bericht.zeitraumBis + ? `${formatDatum(bericht.zeitraumVon)} – ${formatDatum(bericht.zeitraumBis)}` + : bericht.stichtag + ? `Stand ${formatDatum(bericht.stichtag)}` + : null, + erstelltAm: bericht.erstelltAm.toISOString(), + erstelltVon: bericht.erstelltVon?.name ?? null, + dateiname: bericht.dateiname, + downloadUrl: bericht.objektSchluessel + ? await signierteUrl(bericht.objektSchluessel).catch(() => null) + : null, + abgegebenAm: bericht.abgegebenAm ? bericht.abgegebenAm.toISOString() : null, + abgegebenAn: bericht.abgegebenAn, + bearbeitbar: darfSchreiben && bericht.art === 'JAHRESBERICHT', + })), + ) +} diff --git a/src/components/berichte/unterlagenpaket-formular.tsx b/src/components/berichte/unterlagenpaket-formular.tsx new file mode 100644 index 0000000..3f3a1d2 --- /dev/null +++ b/src/components/berichte/unterlagenpaket-formular.tsx @@ -0,0 +1,137 @@ +'use client' + +import * as React from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Paperclip } from 'lucide-react' +import { Feld } from '@/components/feld' +import { Checkbox } from '@/components/ui/checkbox' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Meldung } from '@/components/berichte/meldung' +import { unterlagenpaketAnlegen, type Aktionszustand } from '@/server/actions/berichte' +import { formatDatum } from '@/lib/utils' + +export type NachforderungsAuswahl = { + id: string + bezeichnung: string + status: string + fristBis: string | null + begruendungNichtVorhanden: string | null + anzahlDokumente: number +} + +function Absenden({ anzahl }: { anzahl: number }) { + const { pending } = useFormStatus() + return ( + + ) +} + +/** + * Auswahl der Unterlagen, die an den Sozialhilfetraeger gehen. + * + * Es koennen auch Positionen mitgeschickt werden, zu denen es kein + * Dokument gibt - im Anschreiben erscheinen sie dann mit der hinterlegten + * Begruendung. Das ist wichtig: Eine unbeantwortete Position gilt beim + * Traeger als fehlende Mitwirkung, eine begruendete nicht. + */ +export function UnterlagenpaketFormular({ + betreuteId, + antragId, + traeger, + nachforderungen, +}: { + betreuteId: string + antragId: string + traeger: string + nachforderungen: NachforderungsAuswahl[] +}) { + const [zustand, aktion] = useActionState(unterlagenpaketAnlegen, {}) + const [gewaehlt, setGewaehlt] = React.useState( + nachforderungen.filter((n) => n.anzahlDokumente > 0).map((n) => n.id), + ) + + const umschalten = (id: string) => + setGewaehlt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id])) + + return ( +
+ + + + + +
+ + Welche Unterlagen sollen mitgehen? + +

+ Empfänger ist {traeger}. Angehakt sind alle Positionen, zu denen bereits ein Dokument + hinterlegt ist. Positionen ohne Dokument kannst du mitschicken – sie erscheinen im + Anschreiben mit ihrer Begründung. +

+ +
+
    + {nachforderungen.map((eintrag) => ( +
  • + umschalten(eintrag.id)} + className="mt-1" + /> + +
  • + ))} +
+
+ {zustand.felder?.nachforderungIds && ( +

{zustand.felder.nachforderungIds}

+ )} +
+ + +