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}

+ )} +
+ + +