feat: Jahresbericht, Vollexport, Wissensbasis und Mitleser-Freigaben

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
This commit is contained in:
Florian Wilde
2026-08-22 06:22:44 +02:00
parent b69a12da8f
commit 5cc7f5c140
29 changed files with 5573 additions and 3076 deletions
+143
View File
@@ -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.
@@ -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 (
<div className="space-y-8">
<SeitenKopf
titel="Belegmappe"
pfad={[{ titel: 'Berichte', href: '/berichte' }, { titel: 'Belegmappe' }]}
beschreibung="Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF – mit Deckblatt, laufender Nummer und Verzeichnis, damit ein Beleg auch in zwei Jahren noch auffindbar ist."
/>
<Alert variant="hinweis">
<AlertTitle>Belege gehören zur Rechnungslegung</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
{darfBerichteSchreiben ? (
<BelegmappeFormular
betreuteId={betreuteId}
vorgaenge={vorgaenge.map((vorgang) => ({
id: vorgang.id,
titel:
vorgang.status === 'ERLEDIGT' ? `${vorgang.titel} (erledigt)` : vorgang.titel,
}))}
vorschlagVon={alsIsoTag(tag(vorjahr, 1, 1))}
vorschlagBis={alsIsoTag(tag(vorjahr, 12, 31))}
/>
) : (
<p className="text-sm text-muted-foreground">
Du hast nur Leserechte für die Berichte und kannst deshalb keine Belegmappe erzeugen.
</p>
)}
<HinweisKarten kontext={['BERICHTE', 'FINANZEN', 'DOKUMENTE']} />
<Rechtshinweis rechtsgrundlage="§ 1865, § 1872 BGB" />
</div>
)
}
@@ -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<Response> {
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<Uint8Array>
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',
},
})
}
+79
View File
@@ -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 (
<div className="space-y-8">
<SeitenKopf
titel="Vollexport"
pfad={[{ titel: 'Berichte', href: '/berichte' }, { titel: 'Vollexport' }]}
beschreibung={`Alles, was über ${betreute.vorname} ${betreute.nachname} gespeichert ist, als ein ZIP-Archiv: die Originaldateien, der Datenbestand als JSON, die erzeugten Berichte, eine Anleitung und Prüfsummen.`}
/>
<dl className="grid gap-4 sm:grid-cols-3">
<div className="rounded-md border p-4">
<dt className="text-sm text-muted-foreground">Dateien im Archiv</dt>
<dd className="text-2xl font-semibold tabular-nums">{anzahlDokumente}</dd>
</div>
<div className="rounded-md border p-4">
<dt className="text-sm text-muted-foreground">Erzeugte Berichte</dt>
<dd className="text-2xl font-semibold tabular-nums">{anzahlBerichte}</dd>
</div>
<div className="rounded-md border p-4">
<dt className="text-sm text-muted-foreground">Letzter Export</dt>
<dd className="text-base font-medium">
{letzterExport
? `${formatDatumZeit(letzterExport.erstelltAm)}${
letzterExport.erstelltVon ? ` · ${letzterExport.erstelltVon.name}` : ''
}`
: 'noch keiner'}
</dd>
</div>
</dl>
{darfExportieren ? (
<ExportFormular betreuteId={betreuteId} />
) : (
<Alert variant="warnung">
<AlertTitle>Für den Export fehlt dir die Berechtigung</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
<Rechtshinweis rechtsgrundlage="Art. 20 DSGVO (Datenübertragbarkeit)" />
</div>
)
}
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel={bericht.titel}
pfad={[
{ titel: 'Berichte', href: '/berichte' },
{ titel: 'Jahresbericht', href: '/berichte/jahresbericht' },
{ titel: 'Bearbeiten' },
]}
beschreibung={`Berichtszeitraum ${formatDatum(bericht.zeitraumVon)} bis ${formatDatum(
bericht.zeitraumBis,
)}.`}
aktionen={
downloadUrl ? (
<Button asChild variant="outline">
<a href={downloadUrl} download={bericht.dateiname ?? undefined}>
<Download aria-hidden="true" />
Zuletzt erzeugtes PDF
</a>
</Button>
) : undefined
}
/>
{darfBerichteSchreiben ? (
<EntwurfEditor
berichtId={bericht.id}
entwurf={entwurf}
offeneStellen={offeneErgaenzungen(entwurf)}
ergaenzenMarke={ERGAENZEN}
hatPdf={Boolean(bericht.objektSchluessel)}
/>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Du hast nur Leserechte für die Berichte. Der Entwurf steht hier zum Mitlesen.
</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md border bg-muted/40 p-4 text-sm leading-relaxed">
{entwurf || 'Es ist noch kein Text hinterlegt.'}
</pre>
</div>
)}
{bericht.abgegebenAm && (
<p className="text-sm text-muted-foreground">
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{' '}
<Link href="/berichte" className="underline underline-offset-4">
Übersicht
</Link>
.
</p>
)}
<HinweisKarten kontext={['BERICHTE', 'FRIST:jahresbericht']} grenze={2} />
<Rechtshinweis rechtsgrundlage="§ 1863 Abs. 3 BGB" />
</div>
)
}
@@ -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 (
<div className="space-y-8">
<SeitenKopf
titel="Jahresbericht"
pfad={[{ titel: 'Berichte', href: '/berichte' }, { titel: 'Jahresbericht' }]}
beschreibung="Einmal im Betreuungsjahr berichtest du dem Gericht über die persönlichen Verhältnisse, deine Kontakte, die Wünsche der betreuten Person und darüber, ob die Betreuung so weitergeführt werden soll."
/>
<Alert variant="hinweis">
<AlertTitle>Die Anwendung schreibt einen Entwurf, nicht den Bericht</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
{betreute.bestellungAm ? (
<p className="text-sm text-muted-foreground">
Bestellung am {formatDatum(betreute.bestellungAm)}. Das laufende Betreuungsjahr geht vom{' '}
{formatDatum(von)} bis zum {formatDatum(bis)}.
</p>
) : (
<Alert variant="warnung">
<AlertTitle>Kein Bestellungsdatum hinterlegt</AlertTitle>
<AlertDescription>
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{' '}
<Link href="/stammdaten" className="underline underline-offset-4">
Stammdaten
</Link>{' '}
nach – daran hängen auch die Pflichtfristen.
</AlertDescription>
</Alert>
)}
{darfBerichteSchreiben ? (
<JahresberichtFormular
betreuteId={betreuteId}
vorschlagVon={alsIsoTag(von)}
vorschlagBis={alsIsoTag(bis)}
/>
) : (
<p className="text-sm text-muted-foreground">
Du hast nur Leserechte für die Berichte und kannst deshalb keinen Entwurf erzeugen.
</p>
)}
{vorhandene.length > 0 && (
<section className="space-y-3" aria-label="Bisherige Jahresberichte">
<h2 className="text-base font-semibold">Bisherige Jahresberichte</h2>
<ul className="divide-y rounded-md border">
{vorhandene.map((bericht) => (
<li key={bericht.id} className="flex flex-wrap items-center gap-3 p-3">
<span className="min-w-0 flex-1">
<Link
href={`/berichte/jahresbericht/${bericht.id}`}
className="font-medium underline-offset-4 hover:underline"
>
{bericht.titel}
</Link>
<span className="block text-sm text-muted-foreground">
{formatDatum(bericht.zeitraumVon)} – {formatDatum(bericht.zeitraumBis)}
</span>
</span>
{bericht.abgegebenAm ? (
<Badge variant="gruen">abgegeben am {formatDatum(bericht.abgegebenAm)}</Badge>
) : bericht.objektSchluessel ? (
<Badge variant="gelb">PDF erzeugt, noch nicht abgegeben</Badge>
) : (
<Badge variant="orange">Entwurf in Arbeit</Badge>
)}
</li>
))}
</ul>
</section>
)}
<HinweisKarten kontext={['BERICHTE', 'FRIST:jahresbericht', 'KONTAKTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 Abs. 3 BGB" />
</div>
)
}
+163
View File
@@ -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 (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/>
<section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4">
Fristen
</Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p>
) : (
<ul className="divide-y rounded-md border">
{fristen.map((frist) => {
const tage = tageBis(frist.datum)
const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge>
</li>
)
})}
</ul>
)}
</section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => {
const Icon = weg.icon
return (
<Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0">
<CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel}
</Link>
</CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div>
</CardHeader>
</Card>
)
})}
</section>
<section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section>
<HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
@@ -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<Record<string, string | string[] | undefined>>
}) {
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 (
<div className="space-y-6">
<SeitenKopf
titel="Unterlagen für den Träger"
pfad={[{ titel: 'Berichte', href: '/berichte' }, { titel: 'Unterlagenpaket' }]}
/>
<Leerzustand
titel="Es ist noch kein Antrag erfasst"
beschreibung="Das Unterlagenpaket baut auf dem Nachforderungs-Tracker auf. Lege zuerst den Antrag auf Hilfe zur Pflege an und trage ein, was der Träger angefordert hat."
aktion={
<Button asChild>
<Link href="/sozialhilfe">Zur Sozialhilfe</Link>
</Button>
}
/>
</div>
)
}
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 (
<div className="space-y-8">
<SeitenKopf
titel="Unterlagen für den Träger"
pfad={[{ titel: 'Berichte', href: '/berichte' }, { titel: 'Unterlagenpaket' }]}
beschreibung={`Ein PDF für ${antrag.traeger}: Anschreiben mit laufender Nummerierung, Inhaltsverzeichnis und die Unterlagen in genau dieser Reihenfolge.`}
/>
{antraege.length > 1 && (
<nav aria-label="Antrag wählen" className="flex flex-wrap gap-2">
{antraege.map((eintrag) => (
<Button
key={eintrag.id}
asChild
size="sm"
variant={eintrag.id === antrag.id ? 'default' : 'outline'}
>
<Link href={`/berichte/unterlagenpaket?antrag=${eintrag.id}`}>
{eintrag.leistungsart}
{eintrag.aktenzeichen ? ` · ${eintrag.aktenzeichen}` : ''}
</Link>
</Button>
))}
</nav>
)}
<Alert variant="hinweis">
<AlertTitle>Auch fehlende Unterlagen gehören ins Paket</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
{nachforderungen.length === 0 ? (
<Leerzustand
titel="Keine offenen Nachforderungen"
beschreibung="Sobald der Träger Unterlagen anfordert und du sie im Nachforderungs-Tracker erfasst, kannst du hier ein Paket daraus bauen."
aktion={
<Button asChild>
<Link href="/sozialhilfe">Zum Nachforderungs-Tracker</Link>
</Button>
}
/>
) : darfBerichteSchreiben ? (
<UnterlagenpaketFormular
betreuteId={betreuteId}
antragId={antrag.id}
traeger={antrag.traeger}
nachforderungen={nachforderungen}
/>
) : (
<p className="text-sm text-muted-foreground">
Du hast nur Leserechte für die Berichte und kannst deshalb kein Paket erzeugen.
</p>
)}
<HinweisKarten kontext={['SOZIALHILFE', 'VORGANG:SOZIALHILFE', 'DOKUMENTE']} />
<Rechtshinweis rechtsgrundlage="§ 60 SGB I, § 66 SGB I" />
</div>
)
}
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel="Benutzer"
pfad={[{ titel: 'Einstellungen', href: '/einstellungen' }, { titel: 'Benutzer' }]}
/>
<Alert variant="warnung">
<AlertTitle>Dieser Bereich ist Administratoren vorbehalten</AlertTitle>
<AlertDescription>
Wer Benutzer anlegt und Rollen vergibt, entscheidet darüber, wer die Daten eines
kranken Menschen sehen darf. Diese Entscheidung liegt bewusst an einer Stelle.
</AlertDescription>
</Alert>
</div>
)
}
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 (
<div className="space-y-6">
<SeitenKopf
titel="Benutzer"
pfad={[{ titel: 'Einstellungen', href: '/einstellungen' }, { titel: 'Benutzer' }]}
beschreibung="Wer diese Anwendung benutzen darf. Welche Akten jemand sieht, entscheidest du getrennt davon unter Freigaben."
/>
<BenutzerVerwaltung benutzer={zeilen} />
</div>
)
}
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel="Freigaben"
pfad={[{ titel: 'Einstellungen', href: '/einstellungen' }, { titel: 'Freigaben' }]}
/>
<Alert variant="warnung">
<AlertTitle>Freigaben vergibt nur der Betreuer</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
</div>
)
}
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 (
<div className="space-y-6">
<SeitenKopf
titel="Freigaben"
pfad={[{ titel: 'Einstellungen', href: '/einstellungen' }, { titel: 'Freigaben' }]}
beschreibung={`Wer darf die Akte von ${betreuteName} sehen – und welchen Teil davon? Jede Änderung wird protokolliert und beendet die offenen Anmeldungen des Betroffenen.`}
/>
<Alert variant="hinweis">
<AlertTitle>Freigeben heißt: über die Privatsphäre eines anderen entscheiden</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
<div className="space-y-4">
{zeilen.map((zeile) => (
<FreigabeFormular
key={zeile.userId}
betreuteId={betreute.id}
betreuteName={betreuteName}
zeile={zeile}
/>
))}
</div>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB, Art. 9 DSGVO" />
</div>
)
}
+91
View File
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel="Einstellungen"
pfad={[{ titel: 'Einstellungen' }]}
beschreibung={
<>
Angemeldet als {benutzer.name}{' '}
<Badge variant="outline">{ROLLEN_BESCHRIFTUNG[benutzer.rolle]}</Badge>
</>
}
/>
<div className="grid gap-4 md:grid-cols-2">
{istAdmin && (
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Users className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<CardTitle className="text-lg">
<Link href="/einstellungen/benutzer" className="underline-offset-4 hover:underline">
Benutzer
</Link>
</CardTitle>
<CardDescription className="mt-1">
Anlegen, Rolle ändern, deaktivieren, alle Geräte abmelden. Nur für
Administratoren.
</CardDescription>
</div>
</CardHeader>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<KeyRound className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<CardTitle className="text-lg">
<Link href="/einstellungen/freigaben" className="underline-offset-4 hover:underline">
Freigaben
</Link>
</CardTitle>
<CardDescription className="mt-1">
Wer darf welche Bereiche der Akte lesen? Mitleser bekommen einzelne Bereiche
freigegeben – schreiben dürfen sie nie.
</CardDescription>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Trash2 className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<CardTitle className="text-lg">
<Link href="/einstellungen/papierkorb" className="underline-offset-4 hover:underline">
Papierkorb
</Link>
</CardTitle>
<CardDescription className="mt-1">
Was in den letzten 30 Tagen gelöscht wurde – wiederherstellbar, bis die Frist
abgelaufen ist.
</CardDescription>
</div>
</CardHeader>
</Card>
</div>
</div>
)
}
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel="Papierkorb"
pfad={[{ titel: 'Einstellungen', href: '/einstellungen' }, { titel: 'Papierkorb' }]}
beschreibung={`Gelöschtes aus der Akte von ${betreute.vorname} ${betreute.nachname}. Nach ${PAPIERKORB_TAGE} Tagen wird es endgültig entfernt.`}
/>
<Alert variant="hinweis">
<AlertTitle>Endgültig löschen ist endgültig</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
<PapierkorbListe
betreuteId={betreute.id}
gruppen={gruppen}
darfSchreiben={darfSchreiben}
/>
</div>
)
}
+226
View File
@@ -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 (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{children}
</Button>
)
}
/**
* 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<Aktionszustand, FormData>(berichtLoeschen, {})
const [abgabestand, abgeben] = useActionState<Aktionszustand, FormData>(abgabeVermerken, {})
const [offeneAbgabe, setOffeneAbgabe] = React.useState<string | null>(null)
if (zeilen.length === 0) {
return (
<Leerzustand
titel="Noch kein Bericht erzeugt"
beschreibung="Sobald du einen Jahresbericht, eine Belegmappe oder ein Unterlagenpaket erzeugst, steht es hier – mit Datum, Fassung und der abgelegten Datei."
/>
)
}
return (
<div className="space-y-3">
<Meldung fehler={loeschstand.fehler ?? abgabestand.fehler} erfolg={loeschstand.erfolg ?? abgabestand.erfolg} />
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bericht</TableHead>
<TableHead>Zeitraum</TableHead>
<TableHead>Erzeugt</TableHead>
<TableHead>Abgegeben</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zeilen.map((zeile) => (
<React.Fragment key={zeile.id}>
<TableRow>
<TableCell>
<span className="block font-medium">{zeile.titel}</span>
<Badge variant="outline" className="mt-1">
{zeile.artBeschriftung}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{zeile.zeitraum ?? '–'}</TableCell>
<TableCell className="whitespace-nowrap">
<span className="block">{formatDatumZeit(zeile.erstelltAm)}</span>
{zeile.erstelltVon && (
<span className="text-sm text-muted-foreground">{zeile.erstelltVon}</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
{zeile.abgegebenAm ? (
<>
<Badge variant="gruen">{formatDatum(zeile.abgegebenAm)}</Badge>
{zeile.abgegebenAn && (
<span className="mt-1 block text-sm text-muted-foreground">
an {zeile.abgegebenAn}
</span>
)}
</>
) : (
<Badge variant="gelb">noch nicht vermerkt</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-1">
{zeile.bearbeitbar && (
<Button asChild size="sm" variant="ghost">
<Link href={`/berichte/jahresbericht/${zeile.id}`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
)}
{zeile.downloadUrl && (
<Button asChild size="sm" variant="outline">
<a href={zeile.downloadUrl} download={zeile.dateiname ?? undefined}>
<Download aria-hidden="true" />
Datei
</a>
</Button>
)}
{darfSchreiben && !zeile.abgegebenAm && (
<Button
size="sm"
variant="ghost"
type="button"
onClick={() =>
setOffeneAbgabe((alt) => (alt === zeile.id ? null : zeile.id))
}
aria-expanded={offeneAbgabe === zeile.id}
>
Abgabe vermerken
</Button>
)}
{darfSchreiben && (
<form
action={loeschen}
onSubmit={(ereignis) => {
if (
!window.confirm(
`„${zeile.titel}“ in den Papierkorb verschieben? Dort liegt er 30 Tage.`,
)
) {
ereignis.preventDefault()
}
}}
>
<input type="hidden" name="berichtId" value={zeile.id} />
<KleinerKnopf variante="ghost">
<Trash2 aria-hidden="true" />
<span className="sr-only">Löschen</span>
</KleinerKnopf>
</form>
)}
</div>
</TableCell>
</TableRow>
{offeneAbgabe === zeile.id && (
<TableRow>
<TableCell colSpan={5} className="bg-muted/40">
<form action={abgeben} className="flex flex-wrap items-end gap-3">
<input type="hidden" name="berichtId" value={zeile.id} />
<Feld
id={`abgegebenAm-${zeile.id}`}
beschriftung="Abgegeben am"
fehler={abgabestand.felder?.abgegebenAm}
className="w-44"
pflicht
>
<Input
type="date"
name="abgegebenAm"
defaultValue={new Date().toISOString().slice(0, 10)}
required
/>
</Feld>
<Feld
id={`abgegebenAn-${zeile.id}`}
beschriftung="Vorgelegt bei"
erklaerung="Zum Beispiel „Amtsgericht Bayreuth“ oder „Bezirk Oberfranken“."
fehler={abgabestand.felder?.abgegebenAn}
className="min-w-64 flex-1"
pflicht
>
<Input name="abgegebenAn" required maxLength={200} />
</Feld>
<KleinerKnopf variante="outline">Vermerken</KleinerKnopf>
</form>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
</div>
</div>
)
}
+121
View File
@@ -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 (
<Button type="submit" variant="secondary" disabled={pending}>
<ShieldCheck aria-hidden="true" />
{pending ? 'Wird geprüft …' : 'Export freischalten'}
</Button>
)
}
/**
* 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<Aktionszustand, FormData>(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 (
<div className="space-y-5">
<Alert variant="warnung">
<AlertTitle>Der Export enthält alles – unverschlüsselt</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
<fieldset className="space-y-3">
<legend className="text-base font-semibold">Was soll hinein?</legend>
<label className="flex min-h-11 items-center gap-3">
<Checkbox
checked={mitDokumenten}
onChange={(ereignis) => setMitDokumenten(ereignis.target.checked)}
/>
<span>
<span className="block font-medium">Alle Originaldateien</span>
<span className="text-sm text-muted-foreground">
Bescheide, Kontoauszüge, Fotos – nach Jahren sortiert unter <code>dokumente/</code>.
</span>
</span>
</label>
<label className="flex min-h-11 items-center gap-3">
<Checkbox
checked={mitBerichten}
onChange={(ereignis) => setMitBerichten(ereignis.target.checked)}
/>
<span>
<span className="block font-medium">Erzeugte Berichte</span>
<span className="text-sm text-muted-foreground">
Jahresberichte, Belegmappen, Unterlagenpakete als PDF.
</span>
</span>
</label>
<label className="flex min-h-11 items-center gap-3">
<Checkbox
checked={mitAuditLog}
onChange={(ereignis) => setMitAuditLog(ereignis.target.checked)}
/>
<span>
<span className="block font-medium">Audit-Log</span>
<span className="text-sm text-muted-foreground">
Wer hat wann was geändert. Dein Nachweis gegenüber Gericht und Miterben – lass es
drin, wenn du keinen Grund dagegen hast.
</span>
</span>
</label>
</fieldset>
<form action={aktion} className="space-y-3">
<input type="hidden" name="betreuteId" value={betreuteId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<div className="flex flex-wrap items-center gap-3">
<Bestaetigen />
<Button asChild variant="default">
<a href={`/berichte/export/datei?${abfrage.toString()}`} download>
<Download aria-hidden="true" />
Archiv herunterladen
</a>
</Button>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</form>
</div>
)
}
+79
View File
@@ -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<BerichtArt, string> = {
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<BerichtsZeile[]> {
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',
})),
)
}
@@ -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 (
<Button type="submit" disabled={pending || anzahl === 0}>
<Paperclip aria-hidden="true" />
{pending
? 'Paket wird gebaut …'
: anzahl === 0
? 'Bitte mindestens eine Unterlage wählen'
: `Paket mit ${anzahl} Positionen erzeugen`}
</Button>
)
}
/**
* 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<Aktionszustand, FormData>(unterlagenpaketAnlegen, {})
const [gewaehlt, setGewaehlt] = React.useState<string[]>(
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 (
<form action={aktion} className="space-y-5">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="antragId" value={antragId} />
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
<fieldset className="space-y-2">
<legend className="mb-2 text-base font-semibold">
Welche Unterlagen sollen mitgehen?
</legend>
<p className="mb-3 text-sm text-muted-foreground">
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.
</p>
<div className="max-h-[28rem] overflow-y-auto rounded-md border">
<ul className="divide-y">
{nachforderungen.map((eintrag) => (
<li key={eintrag.id} className="flex items-start gap-3 p-3">
<Checkbox
id={`nf-${eintrag.id}`}
name="nachforderungIds"
value={eintrag.id}
checked={gewaehlt.includes(eintrag.id)}
onChange={() => umschalten(eintrag.id)}
className="mt-1"
/>
<label htmlFor={`nf-${eintrag.id}`} className="min-w-0 flex-1 cursor-pointer">
<span className="block font-medium">{eintrag.bezeichnung}</span>
<span className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{eintrag.anzahlDokumente > 0 ? (
<Badge variant="gruen">
{eintrag.anzahlDokumente === 1
? '1 Dokument hinterlegt'
: `${eintrag.anzahlDokumente} Dokumente hinterlegt`}
</Badge>
) : (
<Badge variant="orange">kein Dokument</Badge>
)}
{eintrag.fristBis && <span>Frist: {formatDatum(eintrag.fristBis)}</span>}
{eintrag.anzahlDokumente === 0 && (
<span>
{eintrag.begruendungNichtVorhanden
? `Begründung: ${eintrag.begruendungNichtVorhanden}`
: 'Ohne Begründung – dann steht ein allgemeiner Satz im Anschreiben.'}
</span>
)}
</span>
</label>
</li>
))}
</ul>
</div>
{zustand.felder?.nachforderungIds && (
<p className="text-sm font-medium text-destructive">{zustand.felder.nachforderungIds}</p>
)}
</fieldset>
<Feld
id="anschreibenZusatz"
beschriftung="Ergänzende Hinweise im Anschreiben"
erklaerung="Landet im Anschreiben unter einer eigenen Überschrift – zum Beispiel ein Hinweis auf eine noch laufende Anfrage bei der Bank."
>
<Textarea name="anschreibenZusatz" rows={4} maxLength={4000} />
</Feld>
<Absenden anzahl={gewaehlt.length} />
</form>
)
}
@@ -0,0 +1,254 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { LogOut, UserPlus } from 'lucide-react'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Meldung } from '@/components/berichte/meldung'
import {
benutzerAktivSetzen,
benutzerAnlegen,
rolleAendern,
sitzungenBeenden,
type Aktionszustand,
} from '@/server/actions/freigaben'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
export type BenutzerZeile = {
id: string
name: string
email: string
rolle: 'BETREUER' | 'MITLESER' | 'ADMIN'
aktiv: boolean
zweiterFaktor: boolean
letzteAnmeldung: string | null
istIchSelbst: boolean
}
const ROLLEN = ['BETREUER', 'MITLESER', 'ADMIN'] as const
function Knopf({
children,
variante = 'outline',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'ghost' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Benutzerverwaltung - nur fuer Administratoren.
*
* Jede Aenderung hier verschiebt Rechte an den Daten eines kranken
* Menschen. Deshalb verlangt jede Aktion serverseitig eine frische
* Anmeldung und schreibt einen Audit-Eintrag; die Oberflaeche sagt vorher,
* was passiert - insbesondere, dass eine Rollenaenderung alle offenen
* Anmeldungen des Betroffenen beendet.
*/
export function BenutzerVerwaltung({ benutzer }: { benutzer: BenutzerZeile[] }) {
const [anlegestand, anlegen] = useActionState<Aktionszustand, FormData>(benutzerAnlegen, {})
const [rollenstand, rolleSetzen] = useActionState<Aktionszustand, FormData>(rolleAendern, {})
const [aktivstand, aktivSetzen] = useActionState<Aktionszustand, FormData>(benutzerAktivSetzen, {})
const [sitzungsstand, beenden] = useActionState<Aktionszustand, FormData>(sitzungenBeenden, {})
const [formularOffen, setFormularOffen] = React.useState(false)
return (
<div className="space-y-6">
<Meldung
fehler={rollenstand.fehler ?? aktivstand.fehler ?? sitzungsstand.fehler}
erfolg={rollenstand.erfolg ?? aktivstand.erfolg ?? sitzungsstand.erfolg}
/>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzer</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Zustand</TableHead>
<TableHead>Zuletzt angemeldet</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benutzer.map((zeile) => (
<TableRow key={zeile.id}>
<TableCell>
<span className="block font-medium">
{zeile.name}
{zeile.istIchSelbst && (
<span className="ml-2 text-sm font-normal text-muted-foreground">(du)</span>
)}
</span>
<span className="text-sm text-muted-foreground">{zeile.email}</span>
</TableCell>
<TableCell>
<form action={rolleSetzen} className="flex items-center gap-2">
<input type="hidden" name="userId" value={zeile.id} />
<label className="sr-only" htmlFor={`rolle-${zeile.id}`}>
Rolle von {zeile.name}
</label>
<Select
id={`rolle-${zeile.id}`}
name="rolle"
defaultValue={zeile.rolle}
className="h-9 w-40 text-sm"
>
{ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_BESCHRIFTUNG[rolle]}
</option>
))}
</Select>
<Knopf variante="ghost" laeuftText="…">
Ändern
</Knopf>
</form>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{zeile.aktiv ? (
<Badge variant="gruen">aktiv</Badge>
) : (
<Badge variant="rot">deaktiviert</Badge>
)}
{zeile.zweiterFaktor ? (
<Badge variant="outline">2FA eingerichtet</Badge>
) : (
<Badge variant="gelb">2FA fehlt noch</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{zeile.letzteAnmeldung ? formatDatumZeit(zeile.letzteAnmeldung) : 'noch nie'}
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-2">
<form action={beenden}>
<input type="hidden" name="userId" value={zeile.id} />
<Knopf variante="ghost" laeuftText="…">
<LogOut aria-hidden="true" />
Abmelden
</Knopf>
</form>
{!zeile.istIchSelbst && (
<form action={aktivSetzen}>
<input type="hidden" name="userId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.aktiv ? 'nein' : 'ja'} />
<Knopf
variante={zeile.aktiv ? 'destructive' : 'outline'}
laeuftText="…"
>
{zeile.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</Knopf>
</form>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="text-sm text-muted-foreground">
„Abmelden“ beendet alle Geräte dieses Benutzers sofort – nützlich bei einem verlorenen
Handy. Eine Rollenänderung tut das automatisch mit, damit niemand seine alten Rechte
behält, bis sein Token abläuft.
</p>
<div className="rounded-md border p-4">
<Button
type="button"
variant={formularOffen ? 'ghost' : 'default'}
onClick={() => setFormularOffen((alt) => !alt)}
aria-expanded={formularOffen}
>
<UserPlus aria-hidden="true" />
{formularOffen ? 'Abbrechen' : 'Benutzer anlegen'}
</Button>
{formularOffen && (
<form action={anlegen} className="mt-4 space-y-4">
<Meldung fehler={anlegestand.fehler} erfolg={anlegestand.erfolg} />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="name"
beschriftung="Name"
fehler={anlegestand.felder?.name}
pflicht
>
<Input name="name" autoComplete="off" required maxLength={120} />
</Feld>
<Feld
id="email"
beschriftung="E-Mail"
erklaerung="Damit meldet sich der Benutzer an."
fehler={anlegestand.felder?.email}
pflicht
>
<Input type="email" name="email" autoComplete="off" required maxLength={200} />
</Feld>
<Feld
id="rolle"
beschriftung="Rolle"
erklaerung={ROLLEN_ERKLAERUNG.MITLESER}
fehler={anlegestand.felder?.rolle}
pflicht
>
<Select name="rolle" defaultValue="MITLESER">
{ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_BESCHRIFTUNG[rolle]} – {ROLLEN_ERKLAERUNG[rolle]}
</option>
))}
</Select>
</Feld>
<Feld
id="passwort"
beschriftung="Erstes Passwort"
erklaerung="Mindestens 12 Zeichen. Ein Satz aus vier Wörtern ist leichter zu merken und sicherer als „Sommer2026!“."
fehler={anlegestand.felder?.passwort}
pflicht
>
<Input type="password" name="passwort" autoComplete="new-password" required />
</Feld>
</div>
<p className="text-sm text-muted-foreground">
Der zweite Faktor wird beim ersten Anmelden eingerichtet. Ohne ihn kommt niemand an
Fachdaten – auch der neue Benutzer nicht.
</p>
<Knopf variante="default" laeuftText="Wird angelegt …">
Benutzer anlegen
</Knopf>
</form>
)}
</div>
</div>
)
}
@@ -0,0 +1,208 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { ShieldAlert, Trash2 } from 'lucide-react'
import type { Modul, Rolle } from '@prisma/client'
import { Feld } from '@/components/feld'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Meldung } from '@/components/berichte/meldung'
import {
ALLE_MODULE,
BESONDERS_SENSIBEL,
MODUL_BESCHRIFTUNG,
ROLLEN_BESCHRIFTUNG,
ROLLEN_ERKLAERUNG,
} from '@/lib/auth/rollen'
import {
freigabenSpeichern,
zugangEntziehen,
type Aktionszustand,
} from '@/server/actions/freigaben'
export type ZugangsZeile = {
userId: string
name: string
email: string
rolle: Rolle
freigegebeneModule: Modul[]
hatZugang: boolean
istIchSelbst: boolean
}
const ROLLEN: Rolle[] = ['BETREUER', 'MITLESER']
function Knopf({
children,
variante = 'default',
laeuftText,
}: {
children: React.ReactNode
variante?: 'default' | 'outline' | 'ghost' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Modulweise Freigabe fuer einen Benutzer und eine betreute Person.
*
* Der wichtigste Teil dieser Komponente ist die Warnung: Gesundheit,
* Finanzen und Sozialhilfe enthalten Daten, die niemanden etwas angehen -
* Diagnosen, Kontostaende, Bedarfslagen. Wer sie an ein Geschwister
* freigibt, soll das bewusst tun und nicht, weil ein Kaestchen
* unauffaellig zwischen zehn anderen stand.
*/
export function FreigabeFormular({
betreuteId,
betreuteName,
zeile,
}: {
betreuteId: string
betreuteName: string
zeile: ZugangsZeile
}) {
const [zustand, speichern] = useActionState<Aktionszustand, FormData>(freigabenSpeichern, {})
const [entzugsstand, entziehen] = useActionState<Aktionszustand, FormData>(zugangEntziehen, {})
const [rolle, setRolle] = React.useState<Rolle>(zeile.rolle)
const [module, setModule] = React.useState<Modul[]>(zeile.freigegebeneModule)
const umschalten = (modul: Modul) =>
setModule((alt) => (alt.includes(modul) ? alt.filter((m) => m !== modul) : [...alt, modul]))
const sensibelGewaehlt = module.filter((m) => BESONDERS_SENSIBEL.includes(m))
const istMitleser = rolle === 'MITLESER'
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold">{zeile.name}</h3>
<p className="text-sm text-muted-foreground">{zeile.email}</p>
</div>
{zeile.hatZugang && !zeile.istIchSelbst && (
<form
action={entziehen}
onSubmit={(ereignis) => {
if (
!window.confirm(
`${zeile.name} den Zugriff auf ${betreuteName} vollständig entziehen?`,
)
) {
ereignis.preventDefault()
}
}}
>
<input type="hidden" name="userId" value={zeile.userId} />
<input type="hidden" name="betreuteId" value={betreuteId} />
<Knopf variante="ghost" laeuftText="…">
<Trash2 aria-hidden="true" />
Zugang entziehen
</Knopf>
</form>
)}
</div>
<Meldung
fehler={zustand.fehler ?? entzugsstand.fehler}
erfolg={zustand.erfolg ?? entzugsstand.erfolg}
warnung={zustand.warnung}
/>
<form action={speichern} className="space-y-4">
<input type="hidden" name="userId" value={zeile.userId} />
<input type="hidden" name="betreuteId" value={betreuteId} />
<Feld
id={`rolle-${zeile.userId}`}
beschriftung={`Rolle für ${betreuteName}`}
erklaerung={ROLLEN_ERKLAERUNG[rolle]}
fehler={zustand.felder?.rolle}
pflicht
>
<Select
name="rolle"
value={rolle}
onChange={(ereignis) => setRolle(ereignis.target.value as Rolle)}
>
{ROLLEN.map((wert) => (
<option key={wert} value={wert}>
{ROLLEN_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
{istMitleser ? (
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">Freigegebene Bereiche</legend>
<div className="grid gap-2 sm:grid-cols-2">
{ALLE_MODULE.map((modul) => {
const sensibel = BESONDERS_SENSIBEL.includes(modul)
return (
<label
key={modul}
className="flex min-h-11 items-center gap-3 rounded-md border px-3 py-2"
>
<Checkbox
name="freigegebeneModule"
value={modul}
checked={module.includes(modul)}
onChange={() => umschalten(modul)}
/>
<span className="min-w-0">
<span className="block">{MODUL_BESCHRIFTUNG[modul]}</span>
{sensibel && (
<span className="flex items-center gap-1 text-sm font-medium text-ampel-orange">
<ShieldAlert className="size-4" aria-hidden="true" />
besonders sensibel
</span>
)}
</span>
</label>
)
})}
</div>
{sensibelGewaehlt.length > 0 && (
<Alert variant="warnung">
<ShieldAlert aria-hidden="true" />
<AlertTitle>Das sind besonders schützenswerte Daten</AlertTitle>
<AlertDescription>
Mit {sensibelGewaehlt.map((m) => MODUL_BESCHRIFTUNG[m]).join(' und ')} gibst du
Diagnosen, Kontostände und die Bedarfslage von {betreuteName} frei. Das ist eine
Entscheidung über die Privatsphäre einer anderen Person – frag dich, ob sie es so
wollen würde (§ 1821 BGB), und gib nur frei, was wirklich gebraucht wird.
</AlertDescription>
</Alert>
)}
<p className="text-sm text-muted-foreground">
Ein Mitleser kann in keinem Bereich etwas ändern, auch nicht im freigegebenen. Was er
nicht sieht, taucht bei ihm auch nicht in der Navigation, der Suche oder im
Papierkorb auf.
</p>
</fieldset>
) : (
<p className="text-sm text-muted-foreground">
Ein Betreuer sieht und ändert alles, was zu {betreuteName} gehört. Eine Auswahl
einzelner Bereiche gibt es dafür nicht – sie würde nur vortäuschen, dass etwas
eingeschränkt wäre.
</p>
)}
<Knopf laeuftText="Wird gespeichert …">Freigabe speichern</Knopf>
</form>
</div>
)
}
@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Meldung } from '@/components/berichte/meldung'
import {
endgueltigLoeschen,
wiederherstellen,
type Aktionszustand,
} from '@/server/actions/papierkorb'
import { formatDatumZeit, relativeTage, tageBis } from '@/lib/utils'
export type PapierkorbGruppe = {
art: string
bezeichnung: string
eintraege: { id: string; bezeichnung: string; geloeschtAm: string; verfaelltAm: string }[]
}
function Knopf({
children,
variante = 'ghost',
laeuftText,
}: {
children: React.ReactNode
variante?: 'ghost' | 'outline' | 'destructive'
laeuftText: string
}) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" variant={variante} disabled={pending}>
{pending ? laeuftText : children}
</Button>
)
}
/**
* Der Papierkorb.
*
* Geloescht heisst hier dreissig Tage lang "beiseitegelegt". Das ist keine
* Bequemlichkeit: In einer Betreuungsakte ist ein versehentlich geloeschter
* Bescheid ein echtes Problem, und der Nachweis gegenueber Gericht und
* Miterben haengt daran. Endgueltig entfernt wird nur einzeln, nach
* Rueckfrage und nach frischer Anmeldung.
*/
export function PapierkorbListe({
betreuteId,
gruppen,
darfSchreiben,
}: {
betreuteId: string
gruppen: PapierkorbGruppe[]
darfSchreiben: boolean
}) {
const [zurueckstand, zurueckholen] = useActionState<Aktionszustand, FormData>(
wiederherstellen,
{},
)
const [loeschstand, loeschen] = useActionState<Aktionszustand, FormData>(endgueltigLoeschen, {})
if (gruppen.length === 0) {
return (
<Leerzustand
titel="Der Papierkorb ist leer"
beschreibung="Was du löschst, landet hier und bleibt 30 Tage wiederherstellbar. Danach wird es endgültig entfernt."
icon={Trash2}
/>
)
}
return (
<div className="space-y-6">
<Meldung
fehler={zurueckstand.fehler ?? loeschstand.fehler}
erfolg={zurueckstand.erfolg ?? loeschstand.erfolg}
/>
{gruppen.map((gruppe) => (
<section key={gruppe.art} className="space-y-2">
<h2 className="text-base font-semibold">
{gruppe.bezeichnung}{' '}
<span className="font-normal text-muted-foreground">({gruppe.eintraege.length})</span>
</h2>
<ul className="divide-y rounded-md border">
{gruppe.eintraege.map((eintrag) => {
const restTage = tageBis(eintrag.verfaelltAm)
return (
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 p-3">
<span className="min-w-0 flex-1">
<span className="block font-medium">{eintrag.bezeichnung}</span>
<span className="text-sm text-muted-foreground">
gelöscht am {formatDatumZeit(eintrag.geloeschtAm)} · wird endgültig entfernt{' '}
{relativeTage(restTage)}
</span>
</span>
{darfSchreiben && (
<div className="flex flex-wrap gap-2">
<form action={zurueckholen}>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="entitaet" value={gruppe.art} />
<input type="hidden" name="entitaetId" value={eintrag.id} />
<Knopf variante="outline" laeuftText="…">
<RotateCcw aria-hidden="true" />
Wiederherstellen
</Knopf>
</form>
<form
action={loeschen}
onSubmit={(ereignis) => {
if (
!window.confirm(
`„${eintrag.bezeichnung}“ endgültig löschen? Das lässt sich nicht ` +
`rückgängig machen. Eine zugehörige Datei wird mit entfernt.`,
)
) {
ereignis.preventDefault()
}
}}
>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="entitaet" value={gruppe.art} />
<input type="hidden" name="entitaetId" value={eintrag.id} />
<Knopf variante="destructive" laeuftText="…">
<Trash2 aria-hidden="true" />
Endgültig löschen
</Knopf>
</form>
</div>
)}
</li>
)
})}
</ul>
</section>
))}
</div>
)
}
+1 -1
View File
@@ -13,7 +13,7 @@ import {
convertMillimetersToTwip, convertMillimetersToTwip,
} from 'docx' } from 'docx'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht' import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/** /**
* DOCX-Ausgabe. * DOCX-Ausgabe.
+134
View File
@@ -0,0 +1,134 @@
import 'server-only'
import { createElement } from 'react'
import type { Berichtskopf } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import {
jahresberichtEntwurfText,
type JahresberichtQuellen,
} from '@/lib/berichte/jahresbericht.entwurf'
import { JahresberichtDokument } from '@/lib/berichte/jahresbericht.inhalt'
/**
* Jahresbericht - Datenbeschaffung und PDF-Ausgabe.
*
* Die Textbausteine stehen in jahresbericht.entwurf.ts und kommen ohne
* Datenbank aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins
* PDF geht, ist IMMER der gespeicherte, vom Betreuer ueberarbeitete
* Entwurfstext - nie ein frisch berechneter. Sonst stuende im abgegebenen
* Bericht etwas anderes als das, was auf dem Bildschirm zur Freigabe stand.
*/
/**
* Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
* Zugriffsberechtigung bereits geprueft (erfordereZugang); die Abfragen
* sind trotzdem durchgehend auf die betreuteId eingegrenzt.
*/
export async function jahresberichtQuellenLaden(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<JahresberichtQuellen> {
const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
db.kontaktprotokoll.findMany({
where: { betreuteId, datum: { gte: zeitraumVon, lte: zeitraumBis }, ...nichtGeloescht },
orderBy: { datum: 'asc' },
}),
db.gesundheitsereignis.findMany({
where: { betreuteId, datum: { gte: zeitraumVon, lte: zeitraumBis }, ...nichtGeloescht },
orderBy: { datum: 'asc' },
}),
db.vorgang.findMany({
where: {
betreuteId,
...nichtGeloescht,
// Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
// Bericht des Vorjahres - hier waere es nur Ballast.
OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
},
orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
}),
])
const betreute = oeffnen('Betreute', roheBetreute)
return {
betreute: {
anrede: betreute.anrede,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsdatum: betreute.geburtsdatum,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimEinzugAm: betreute.heimEinzugAm,
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: betreute.pflegegradSeit,
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: betreute.diagnosen,
ermittelterWille: betreute.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: betreute.bestellungAm,
bestellungBis: betreute.bestellungBis,
},
zeitraumVon,
zeitraumBis,
kontakte: kontakte.map((k) => {
const offen = oeffnen('Kontaktprotokoll', k)
return {
datum: k.datum,
art: k.art,
mitBetreuter: k.mitBetreuter,
dauerMinuten: k.dauerMinuten,
gespraechspartner: k.gespraechspartner,
wuensche: offen.wuensche,
}
}),
gesundheit: gesundheit.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
art: e.art,
titel: e.titel,
einrichtung: e.einrichtung,
abwesenheitstage: e.abwesenheitstage,
})),
vorgaenge: vorgaenge.map((v) => ({
titel: v.titel,
kategorie: v.kategorie,
status: v.status,
naechsterSchritt: v.naechsterSchritt,
gegenstelle: v.gegenstelle,
genehmigungspflichtig: v.genehmigungspflichtig,
erledigtAm: v.erledigtAm,
})),
}
}
/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu aufbauen". */
export async function jahresberichtEntwurfAusDatenbank(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<string> {
return jahresberichtEntwurfText(
await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
)
}
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
return pdfErzeugen(createElement(JahresberichtDokument, { kopf, text }))
}
+694
View File
@@ -0,0 +1,694 @@
import type {
Aufgabenkreis,
GesundheitsArt,
KontaktArt,
VorgangKategorie,
VorgangStatus,
} from '@prisma/client'
import { formatDatum } from '@/lib/utils'
/**
* Jahresbericht nach § 1863 BGB - Textentwurf.
*
* Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
* zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
* und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
* daraus wird, entscheidet der Betreuer: Der Text ist im Browser
* bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
*
* Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
* dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
* Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
* wurden, tauchen gar nicht auf.
*
* Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
* Daten, damit die Textbausteine testbar bleiben.
*/
// -----------------------------------------------------------------------------
// Eingangsdaten
// -----------------------------------------------------------------------------
export type JahresberichtBetreute = {
anrede: string | null
vorname: string
nachname: string
geburtsdatum: Date | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimEinzugAm: Date | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
pflegegrad: number | null
pflegegradSeit: Date | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
/** Klartext aus diagnosenVerschluesselt. */
diagnosen: string | null
/** Klartext aus ermittelterWilleVerschluesselt. */
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
aufgabenkreise: Aufgabenkreis[]
aufgabenkreiseFreitext: string | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
bestellungBis: Date | null
}
export type JahresberichtKontakt = {
datum: Date
art: KontaktArt
mitBetreuter: boolean
dauerMinuten: number | null
gespraechspartner: string | null
/** Klartext aus wuenscheVerschluesselt. */
wuensche: string | null
}
export type JahresberichtGesundheit = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
titel: string
einrichtung: string | null
abwesenheitstage: number | null
}
export type JahresberichtVorgang = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
naechsterSchritt: string | null
gegenstelle: string | null
genehmigungspflichtig: boolean
erledigtAm: Date | null
}
export type JahresberichtQuellen = {
betreute: JahresberichtBetreute
zeitraumVon: Date
zeitraumBis: Date
kontakte: JahresberichtKontakt[]
gesundheit: JahresberichtGesundheit[]
vorgaenge: JahresberichtVorgang[]
}
export type Berichtsabschnitt = {
nummer: number
titel: string
rechtsgrundlage: string
absaetze: string[]
}
// -----------------------------------------------------------------------------
// Beschriftungen
// -----------------------------------------------------------------------------
/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
export const ERGAENZEN = '[bitte ergänzen]'
export const ENTWURFSHINWEIS =
'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
export const KONTAKTART_EINZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuche',
KRANKENHAUS: 'Krankenhausaufenthalte',
EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
STURZ: 'Stürze',
MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
INHALATION: 'Inhalationsbehandlungen',
HILFSMITTEL: 'Hilfsmittelversorgungen',
IMPFUNG: 'Impfungen',
REHA: 'Rehabilitationsmaßnahmen',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
HEILMITTEL: 'Heilmittelbehandlungen',
SONSTIGES: 'sonstige gesundheitliche Ereignisse',
}
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
IMMOBILIE: 'Immobilie und Wohnraum',
VERSICHERUNG: 'Versicherungen',
HEIM_PFLEGE: 'Heim und Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const AUFGABENKREIS_BESCHRIFTUNG: Record<Aufgabenkreis, string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'weitere Aufgaben',
}
export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
{ nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
{
nummer: 2,
titel: 'Persönlicher Kontakt zur betreuten Person',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
},
{
nummer: 3,
titel: 'Wünsche der betreuten Person und ihre Umsetzung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
},
{
nummer: 4,
titel: 'Erledigte und laufende Angelegenheiten',
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
},
{
nummer: 5,
titel: 'Einschätzung zur Fortführung der Betreuung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
},
]
// -----------------------------------------------------------------------------
// Sprachliche Helfer
// -----------------------------------------------------------------------------
/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
export function aufzaehlung(teile: readonly string[]): string {
const sauber = teile.filter((t) => t.trim() !== '')
if (sauber.length === 0) return ''
if (sauber.length === 1) return sauber[0]!
return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
}
/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
export function malAngabe(anzahl: number): string {
const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
return woerter[anzahl] ?? `${anzahl}-mal`
}
/** 95 Minuten -> "1 Stunde und 35 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
const teile: string[] = []
if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
return aufzaehlung(teile)
}
/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
export function vollerName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
}
/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
export function kurzName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
}
/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
function pronomen(betreute: Pick<JahresberichtBetreute, 'anrede'>): { nominativ: string; possessiv: string } {
const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
}
function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
return zeile.trim() === '' ? null : zeile
}
function tageZwischen(von: Date, bis: Date): number {
return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
}
function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
}
function haeufigkeit<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
return karte
}
// -----------------------------------------------------------------------------
// Die fuenf Abschnitte
// -----------------------------------------------------------------------------
function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const pron = pronomen(betreute)
const absaetze: string[] = []
// --- Aufenthalt ---
const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
if (betreute.heimName) {
const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
`in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
`im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
)
} else {
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
`liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
`Berichtszeitraum gelebt hat.`,
)
}
const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
if (frueher) {
absaetze.push(
`Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
`Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
)
}
// --- Pflege und Behinderung ---
const pflegesaetze: string[] = []
if (betreute.pflegegrad !== null) {
const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
} else {
pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
}
if (betreute.schwerbehinderungGdB !== null) {
const merk = betreute.schwerbehinderungMerkzeichen
? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
: ''
pflegesaetze.push(
`Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
)
}
absaetze.push(pflegesaetze.join(' '))
// --- Gesundheitszustand im Berichtszeitraum ---
const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
const gesundheitssaetze: string[] = []
if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
gesundheitssaetze.push(
`Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
)
}
if (ereignisse.length === 0) {
gesundheitssaetze.push(
`Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
`keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
)
} else {
const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
const aufzaehlungTeile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
gesundheitssaetze.push(
`Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
`${aufzaehlung(aufzaehlungTeile)}.`,
)
const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
if (klinik.length > 0) {
const beschreibungen = klinik.map((e) => {
const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
})
gesundheitssaetze.push(
`Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
)
}
const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
if (abwesend > 0) {
gesundheitssaetze.push(
`Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
)
}
}
absaetze.push(gesundheitssaetze.join(' '))
// --- Vorsorge ---
const vorsorge: string[] = []
vorsorge.push(
betreute.patientenverfuegungVorhanden
? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
: 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
)
vorsorge.push(
betreute.vorsorgevollmachtVorhanden
? 'Eine Vorsorgevollmacht ist vorhanden.'
: 'Eine Vorsorgevollmacht ist nicht vorhanden.',
)
absaetze.push(vorsorge.join(' '))
return absaetze
}
function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
const mitIhr = imFenster
.filter((k) => k.mitBetreuter)
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
absaetze.push(
`Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
`${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
)
if (mitIhr.length === 0) {
absaetze.push(
`Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
`nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
`persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
`nach — ${ERGAENZEN}.`,
)
} else {
const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1
? `ein ${KONTAKTART_EINZAHL[art]}`
: `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
absaetze.push(
`Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
`${aufzaehlung(teile)}.`,
)
const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
const luecken = mitIhr
.slice(1)
.map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
if (groessteLuecke > 0) {
rhythmus.push(
`Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
)
}
rhythmus.push(
`Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
`der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
)
absaetze.push(rhythmus.join(' '))
const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
if (minuten > 0) {
absaetze.push(
`Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
`wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
`das Befinden und die persönliche Lage von ${name}.`,
)
}
}
if (mitDritten.length > 0) {
const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
absaetze.push(
`Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
`${aufzaehlung(teile)}.${wer}`,
)
}
return absaetze
}
function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
absaetze.push(
`Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
`${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
`festgehalten.`,
)
const mitWunsch = quellen.kontakte
.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
.filter((k) => (k.wuensche ?? '').trim() !== '')
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
if (mitWunsch.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
`${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
`Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
)
} else {
absaetze.push(
`Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
)
for (const kontakt of mitWunsch) {
absaetze.push(
`• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
`${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
)
}
absaetze.push(
`Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
`entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
)
}
if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
absaetze.push(
`Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
`${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
)
}
return absaetze
}
function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
const { zeitraumVon, zeitraumBis } = quellen
const absaetze: string[] = []
if (quellen.vorgaenge.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
`hast du bearbeitet?`,
)
return absaetze
}
const erledigt = quellen.vorgaenge.filter(
(v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
absaetze.push(
`Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
`${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
)
const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
)
for (const kategorie of kategorien) {
const fertig = erledigt.filter((v) => v.kategorie === kategorie)
const offen = laufend.filter((v) => v.kategorie === kategorie)
const saetze: string[] = []
if (fertig.length > 0) {
const titel = fertig.map((v) =>
v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
)
saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
}
if (offen.length > 0) {
const titel = offen.map((v) => {
const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
return `${v.titel}${wer}${schritt}`
})
saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
}
absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
}
const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
if (genehmigungspflichtig.length > 0) {
absaetze.push(
`Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
`Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
)
}
return absaetze
}
function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
const { betreute } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
kreise.push(betreute.aufgabenkreiseFreitext.trim())
}
absaetze.push(
kreise.length > 0
? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
: `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
const begruendung =
laufend.length > 0
? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
`${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
`ist daher nicht erkennbar.`
: `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
absaetze.push(
`Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
`fortzuführen. ${begruendung}`,
)
absaetze.push(
`Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
`einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
`${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
`einzuschränken oder aufzuheben.`,
)
absaetze.push(
`${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
`falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
)
return absaetze
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
* gleicher Text - damit sich der Entwurf testen laesst.
*/
export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
const bauer = [
abschnittPersoenlicheVerhaeltnisse,
abschnittKontakt,
abschnittWuensche,
abschnittAngelegenheiten,
abschnittEinschaetzung,
]
return GLIEDERUNG.map((eintrag, i) => ({
nummer: eintrag.nummer,
titel: eintrag.titel,
rechtsgrundlage: eintrag.rechtsgrundlage,
absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
}))
}
/**
* Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
* abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
* damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
*/
export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
return abschnitte
.map((abschnitt) =>
[`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
)
.join('\n\n')
.trim()
}
/** Direktweg von den Quellen zum Text. */
export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
}
/**
* Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
* ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
* nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
* auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
*/
export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
const zeilen = text.replace(/\r\n/g, '\n').split('\n')
const abschnitte: { titel: string | null; absaetze: string[] }[] = []
let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
for (const zeile of zeilen) {
if (/^\s*\d+\.\s+\S/.test(zeile)) {
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
aktuell = { titel: zeile.trim(), absaetze: [] }
continue
}
if (zeile.trim() === '') continue
aktuell.absaetze.push(zeile.trim())
}
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
return abschnitte
}
/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
export function offeneErgaenzungen(text: string): number {
return text.split(ERGAENZEN).length - 1
}
+89
View File
@@ -0,0 +1,89 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsdokument, type Berichtskopf } from '@/lib/pdf/rahmen'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/**
* Der Satz des Jahresberichts fuer das PDF.
*
* Warum liegt das nicht in jahresbericht.tsx? Weil neben dieser Datei eine
* jahresbericht.ts steht und beide Dateinamen sich nur in der Endung
* unterscheiden. Webpack probiert ".tsx" vor ".ts", TypeScript und Vitest
* umgekehrt - ein Import ohne Endung waere je nach Werkzeug eine andere
* Datei. Deshalb tragen die Bausteine eindeutige Namen
* (jahresbericht.entwurf, jahresbericht.inhalt, jahresbericht.ausgabe),
* und jahresbericht.ts wie jahresbericht.tsx reichen sie unveraendert
* weiter. So bekommt jeder Aufrufer dieselben Namen, egal welches Werkzeug
* aufloest.
*/
const stile = StyleSheet.create({
ueberschrift: {
fontSize: 12,
fontFamily: 'Helvetica-Bold',
marginTop: 14,
marginBottom: 4,
},
absatz: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 6,
textAlign: 'justify',
},
aufzaehlung: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 3,
marginLeft: 12,
},
vorspann: {
fontSize: 9,
lineHeight: 1.4,
marginBottom: 10,
color: '#4b5563',
},
})
/**
* Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
* fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
* Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
* das sich im Textfeld auch von Hand fortschreiben laesst.
*/
export function JahresberichtInhalt({ text }: { text: string }): React.JSX.Element {
const abschnitte = textZuAbschnitten(text)
return (
<View>
{abschnitte.map((abschnitt, i) => (
<View key={`abschnitt-${i}`} wrap={true}>
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
{abschnitt.absaetze.map((absatz, j) => (
<Text
key={`absatz-${i}-${j}`}
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
>
{absatz}
</Text>
))}
</View>
))}
</View>
)
}
export function JahresberichtDokument({
kopf,
text,
vorspann,
}: {
kopf: Berichtskopf
text: string
vorspann?: string
}): React.JSX.Element {
return (
<Berichtsdokument kopf={kopf} mitUnterschrift={true}>
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
<JahresberichtInhalt text={text} />
</Berichtsdokument>
)
}
+19 -688
View File
@@ -1,694 +1,25 @@
import type {
Aufgabenkreis,
GesundheitsArt,
KontaktArt,
VorgangKategorie,
VorgangStatus,
} from '@prisma/client'
import { formatDatum } from '@/lib/utils'
/** /**
* Jahresbericht nach § 1863 BGB - Textentwurf. * Jahresbericht nach § 1863 BGB - gemeinsame Anlaufstelle.
* *
* Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst * Der Bericht besteht aus drei Bausteinen:
* zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
* und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
* daraus wird, entscheidet der Betreuer: Der Text ist im Browser
* bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
* *
* Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus * jahresbericht.entwurf.ts Textbausteine, rein, ohne Datenbank
* dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der * jahresbericht.inhalt.tsx Satz fuer das PDF (React)
* Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert * jahresbericht.ausgabe.ts Daten laden und PDF erzeugen
* wurden, tauchen gar nicht auf.
* *
* Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen * Warum diese Aufteilung, und warum diese Datei? Neben ihr liegt eine
* Daten, damit die Textbausteine testbar bleiben. * gleichnamige .tsx. Webpack probiert beim Import ohne Endung ".tsx" vor
* ".ts", TypeScript und Vitest nehmen die ".ts" - derselbe Importpfad
* waere je nach Werkzeug eine andere Datei. Deshalb tragen die Bausteine
* eindeutige Namen, und die beiden gleichnamigen Dateien reichen exakt
* dieselben Namen weiter. Wer "@/lib/berichte/jahresbericht" importiert,
* bekommt so immer dieselbe Schnittstelle.
*
* Wer nur die Textbausteine braucht - Tests, die DOCX-Ausgabe -, importiert
* besser direkt "@/lib/berichte/jahresbericht.entwurf": das spart Datenbank
* und PDF-Bibliothek.
*/ */
// ----------------------------------------------------------------------------- export * from '@/lib/berichte/jahresbericht.entwurf'
// Eingangsdaten export * from '@/lib/berichte/jahresbericht.inhalt'
// ----------------------------------------------------------------------------- export * from '@/lib/berichte/jahresbericht.ausgabe'
export type JahresberichtBetreute = {
anrede: string | null
vorname: string
nachname: string
geburtsdatum: Date | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimEinzugAm: Date | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
pflegegrad: number | null
pflegegradSeit: Date | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
/** Klartext aus diagnosenVerschluesselt. */
diagnosen: string | null
/** Klartext aus ermittelterWilleVerschluesselt. */
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
aufgabenkreise: Aufgabenkreis[]
aufgabenkreiseFreitext: string | null
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: Date | null
bestellungBis: Date | null
}
export type JahresberichtKontakt = {
datum: Date
art: KontaktArt
mitBetreuter: boolean
dauerMinuten: number | null
gespraechspartner: string | null
/** Klartext aus wuenscheVerschluesselt. */
wuensche: string | null
}
export type JahresberichtGesundheit = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
titel: string
einrichtung: string | null
abwesenheitstage: number | null
}
export type JahresberichtVorgang = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
naechsterSchritt: string | null
gegenstelle: string | null
genehmigungspflichtig: boolean
erledigtAm: Date | null
}
export type JahresberichtQuellen = {
betreute: JahresberichtBetreute
zeitraumVon: Date
zeitraumBis: Date
kontakte: JahresberichtKontakt[]
gesundheit: JahresberichtGesundheit[]
vorgaenge: JahresberichtVorgang[]
}
export type Berichtsabschnitt = {
nummer: number
titel: string
rechtsgrundlage: string
absaetze: string[]
}
// -----------------------------------------------------------------------------
// Beschriftungen
// -----------------------------------------------------------------------------
/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
export const ERGAENZEN = '[bitte ergänzen]'
export const ENTWURFSHINWEIS =
'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
export const KONTAKTART_EINZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videotelefonat',
FAX: 'Fax',
VORSPRACHE: 'persönliche Vorsprache',
}
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
BESUCH: 'Besuche',
TELEFONAT: 'Telefonate',
EMAIL: 'E-Mails',
BRIEF: 'Briefe',
VIDEOCALL: 'Videotelefonate',
FAX: 'Faxe',
VORSPRACHE: 'persönliche Vorsprachen',
}
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuche',
KRANKENHAUS: 'Krankenhausaufenthalte',
EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
STURZ: 'Stürze',
MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
INHALATION: 'Inhalationsbehandlungen',
HILFSMITTEL: 'Hilfsmittelversorgungen',
IMPFUNG: 'Impfungen',
REHA: 'Rehabilitationsmaßnahmen',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
HEILMITTEL: 'Heilmittelbehandlungen',
SONSTIGES: 'sonstige gesundheitliche Ereignisse',
}
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
IMMOBILIE: 'Immobilie und Wohnraum',
VERSICHERUNG: 'Versicherungen',
HEIM_PFLEGE: 'Heim und Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const AUFGABENKREIS_BESCHRIFTUNG: Record<Aufgabenkreis, string> = {
VERMOEGENSSORGE: 'Vermögenssorge',
GESUNDHEITSSORGE: 'Gesundheitssorge',
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
'Entscheidung über freiheitsentziehende Maßnahmen',
SONSTIGES: 'weitere Aufgaben',
}
export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
{ nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
{
nummer: 2,
titel: 'Persönlicher Kontakt zur betreuten Person',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
},
{
nummer: 3,
titel: 'Wünsche der betreuten Person und ihre Umsetzung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
},
{
nummer: 4,
titel: 'Erledigte und laufende Angelegenheiten',
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
},
{
nummer: 5,
titel: 'Einschätzung zur Fortführung der Betreuung',
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
},
]
// -----------------------------------------------------------------------------
// Sprachliche Helfer
// -----------------------------------------------------------------------------
/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
export function aufzaehlung(teile: readonly string[]): string {
const sauber = teile.filter((t) => t.trim() !== '')
if (sauber.length === 0) return ''
if (sauber.length === 1) return sauber[0]!
return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
}
/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
export function malAngabe(anzahl: number): string {
const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
return woerter[anzahl] ?? `${anzahl}-mal`
}
/** 95 Minuten -> "1 Stunde und 35 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
const teile: string[] = []
if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
return aufzaehlung(teile)
}
/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
export function vollerName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
}
/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
export function kurzName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
}
/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
function pronomen(betreute: Pick<JahresberichtBetreute, 'anrede'>): { nominativ: string; possessiv: string } {
const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
}
function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
return zeile.trim() === '' ? null : zeile
}
function tageZwischen(von: Date, bis: Date): number {
return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
}
function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
}
function haeufigkeit<T extends string>(werte: T[]): Map<T, number> {
const karte = new Map<T, number>()
for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
return karte
}
// -----------------------------------------------------------------------------
// Die fuenf Abschnitte
// -----------------------------------------------------------------------------
function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const pron = pronomen(betreute)
const absaetze: string[] = []
// --- Aufenthalt ---
const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
if (betreute.heimName) {
const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
`in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
`im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
)
} else {
absaetze.push(
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
`liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
`Berichtszeitraum gelebt hat.`,
)
}
const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
if (frueher) {
absaetze.push(
`Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
`Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
)
}
// --- Pflege und Behinderung ---
const pflegesaetze: string[] = []
if (betreute.pflegegrad !== null) {
const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
} else {
pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
}
if (betreute.schwerbehinderungGdB !== null) {
const merk = betreute.schwerbehinderungMerkzeichen
? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
: ''
pflegesaetze.push(
`Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
)
}
absaetze.push(pflegesaetze.join(' '))
// --- Gesundheitszustand im Berichtszeitraum ---
const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
const gesundheitssaetze: string[] = []
if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
gesundheitssaetze.push(
`Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
)
}
if (ereignisse.length === 0) {
gesundheitssaetze.push(
`Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
`keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
)
} else {
const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
const aufzaehlungTeile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
gesundheitssaetze.push(
`Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
`${aufzaehlung(aufzaehlungTeile)}.`,
)
const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
if (klinik.length > 0) {
const beschreibungen = klinik.map((e) => {
const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
})
gesundheitssaetze.push(
`Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
)
}
const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
if (abwesend > 0) {
gesundheitssaetze.push(
`Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
)
}
}
absaetze.push(gesundheitssaetze.join(' '))
// --- Vorsorge ---
const vorsorge: string[] = []
vorsorge.push(
betreute.patientenverfuegungVorhanden
? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
: 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
)
vorsorge.push(
betreute.vorsorgevollmachtVorhanden
? 'Eine Vorsorgevollmacht ist vorhanden.'
: 'Eine Vorsorgevollmacht ist nicht vorhanden.',
)
absaetze.push(vorsorge.join(' '))
return absaetze
}
function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
const mitIhr = imFenster
.filter((k) => k.mitBetreuter)
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
absaetze.push(
`Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
`${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
)
if (mitIhr.length === 0) {
absaetze.push(
`Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
`nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
`persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
`nach — ${ERGAENZEN}.`,
)
} else {
const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1
? `ein ${KONTAKTART_EINZAHL[art]}`
: `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
absaetze.push(
`Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
`${aufzaehlung(teile)}.`,
)
const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
const luecken = mitIhr
.slice(1)
.map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
if (groessteLuecke > 0) {
rhythmus.push(
`Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
)
}
rhythmus.push(
`Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
`der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
)
absaetze.push(rhythmus.join(' '))
const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
if (minuten > 0) {
absaetze.push(
`Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
`wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
`das Befinden und die persönliche Lage von ${name}.`,
)
}
}
if (mitDritten.length > 0) {
const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
const teile = [...nachArt.entries()]
.sort((a, b) => b[1] - a[1])
.map(([art, anzahl]) =>
anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
)
const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
absaetze.push(
`Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
`${aufzaehlung(teile)}.${wer}`,
)
}
return absaetze
}
function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
const { betreute, zeitraumVon, zeitraumBis } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
absaetze.push(
`Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
`${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
`festgehalten.`,
)
const mitWunsch = quellen.kontakte
.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
.filter((k) => (k.wuensche ?? '').trim() !== '')
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
if (mitWunsch.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
`${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
`Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
)
} else {
absaetze.push(
`Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
)
for (const kontakt of mitWunsch) {
absaetze.push(
`• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
`${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
)
}
absaetze.push(
`Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
`entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
)
}
if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
absaetze.push(
`Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
`${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
)
}
return absaetze
}
function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
const { zeitraumVon, zeitraumBis } = quellen
const absaetze: string[] = []
if (quellen.vorgaenge.length === 0) {
absaetze.push(
`Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
`hast du bearbeitet?`,
)
return absaetze
}
const erledigt = quellen.vorgaenge.filter(
(v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
absaetze.push(
`Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
`${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
)
const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
)
for (const kategorie of kategorien) {
const fertig = erledigt.filter((v) => v.kategorie === kategorie)
const offen = laufend.filter((v) => v.kategorie === kategorie)
const saetze: string[] = []
if (fertig.length > 0) {
const titel = fertig.map((v) =>
v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
)
saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
}
if (offen.length > 0) {
const titel = offen.map((v) => {
const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
return `${v.titel}${wer}${schritt}`
})
saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
}
absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
}
const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
if (genehmigungspflichtig.length > 0) {
absaetze.push(
`Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
`Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
)
}
return absaetze
}
function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
const { betreute } = quellen
const name = kurzName(betreute)
const absaetze: string[] = []
const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
kreise.push(betreute.aufgabenkreiseFreitext.trim())
}
absaetze.push(
kreise.length > 0
? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
: `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
)
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
const begruendung =
laufend.length > 0
? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
`${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
`ist daher nicht erkennbar.`
: `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
absaetze.push(
`Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
`fortzuführen. ${begruendung}`,
)
absaetze.push(
`Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
`einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
`${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
`einzuschränken oder aufzuheben.`,
)
absaetze.push(
`${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
`falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
)
return absaetze
}
// -----------------------------------------------------------------------------
// Zusammenbau
// -----------------------------------------------------------------------------
/**
* Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
* gleicher Text - damit sich der Entwurf testen laesst.
*/
export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
const bauer = [
abschnittPersoenlicheVerhaeltnisse,
abschnittKontakt,
abschnittWuensche,
abschnittAngelegenheiten,
abschnittEinschaetzung,
]
return GLIEDERUNG.map((eintrag, i) => ({
nummer: eintrag.nummer,
titel: eintrag.titel,
rechtsgrundlage: eintrag.rechtsgrundlage,
absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
}))
}
/**
* Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
* abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
* damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
*/
export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
return abschnitte
.map((abschnitt) =>
[`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
)
.join('\n\n')
.trim()
}
/** Direktweg von den Quellen zum Text. */
export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
}
/**
* Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
* ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
* nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
* auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
*/
export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
const zeilen = text.replace(/\r\n/g, '\n').split('\n')
const abschnitte: { titel: string | null; absaetze: string[] }[] = []
let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
for (const zeile of zeilen) {
if (/^\s*\d+\.\s+\S/.test(zeile)) {
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
aktuell = { titel: zeile.trim(), absaetze: [] }
continue
}
if (zeile.trim() === '') continue
aktuell.absaetze.push(zeile.trim())
}
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
return abschnitte
}
/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
export function offeneErgaenzungen(text: string): number {
return text.split(ERGAENZEN).length - 1
}
+21 -230
View File
@@ -1,234 +1,25 @@
import 'server-only'
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
// Die Endung steht hier absichtlich: Neben dieser Datei liegt
// jahresbericht.ts mit den reinen Textbausteinen. Ein Import ohne Endung
// waere mehrdeutig - Webpack probiert ".tsx" vor ".ts" und wuerde diese
// Datei sich selbst importieren, TypeScript und Vitest wuerden die ".ts"
// nehmen. Mit ausgeschriebener Endung greifen alle drei dieselbe Datei.
// Damit trotzdem jeder Aufrufer alles unter einem Namen findet, wird die
// reine Schnittstelle unten vollstaendig weitergereicht.
import {
jahresberichtEntwurfText,
textZuAbschnitten,
type JahresberichtQuellen,
} from './jahresbericht.ts'
export * from './jahresbericht.ts'
/** /**
* Jahresbericht - Datenbeschaffung und PDF. * Jahresbericht nach § 1863 BGB - gemeinsame Anlaufstelle (Zwilling).
* *
* Die Textbausteine stehen in jahresbericht.ts und kommen ohne Datenbank * Der Bericht besteht aus drei Bausteinen:
* aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins PDF geht, *
* ist IMMER der gespeicherte, vom Betreuer ueberarbeitete Entwurfstext - * jahresbericht.entwurf.ts Textbausteine, rein, ohne Datenbank
* nie ein frisch berechneter. Sonst stuende im abgegebenen Bericht etwas * jahresbericht.inhalt.tsx Satz fuer das PDF (React)
* anderes als das, was auf dem Bildschirm zur Freigabe stand. * jahresbericht.ausgabe.ts Daten laden und PDF erzeugen
*
* Warum diese Aufteilung, und warum diese Datei? Neben ihr liegt eine
* gleichnamige .tsx. Webpack probiert beim Import ohne Endung ".tsx" vor
* ".ts", TypeScript und Vitest nehmen die ".ts" - derselbe Importpfad
* waere je nach Werkzeug eine andere Datei. Deshalb tragen die Bausteine
* eindeutige Namen, und die beiden gleichnamigen Dateien reichen exakt
* dieselben Namen weiter. Wer "@/lib/berichte/jahresbericht" importiert,
* bekommt so immer dieselbe Schnittstelle.
*
* Wer nur die Textbausteine braucht - Tests, die DOCX-Ausgabe -, importiert
* besser direkt "@/lib/berichte/jahresbericht.entwurf": das spart Datenbank
* und PDF-Bibliothek.
*/ */
// ----------------------------------------------------------------------------- export * from '@/lib/berichte/jahresbericht.entwurf'
// Quelldaten aus der Datenbank export * from '@/lib/berichte/jahresbericht.inhalt'
// ----------------------------------------------------------------------------- export * from '@/lib/berichte/jahresbericht.ausgabe'
/**
* Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
* Zugriffsberechtigung bereits geprueft (erfordereZugang) - diese Funktion
* fragt nicht noch einmal, sie eng auf die betreuteId zu begrenzen ist
* aber Pflicht.
*/
export async function jahresberichtQuellenLaden(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<JahresberichtQuellen> {
const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
db.kontaktprotokoll.findMany({
where: {
betreuteId,
datum: { gte: zeitraumVon, lte: zeitraumBis },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
}),
db.gesundheitsereignis.findMany({
where: {
betreuteId,
datum: { gte: zeitraumVon, lte: zeitraumBis },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
}),
db.vorgang.findMany({
where: {
betreuteId,
...nichtGeloescht,
// Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
// Bericht des Vorjahres - hier waere es nur Ballast.
OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
},
orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
}),
])
const betreute = oeffnen('Betreute', roheBetreute)
return {
betreute: {
anrede: betreute.anrede,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsdatum: betreute.geburtsdatum,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimEinzugAm: betreute.heimEinzugAm,
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: betreute.pflegegradSeit,
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: betreute.diagnosen,
ermittelterWille: betreute.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: betreute.bestellungAm,
bestellungBis: betreute.bestellungBis,
},
zeitraumVon,
zeitraumBis,
kontakte: kontakte.map((k) => {
const offen = oeffnen('Kontaktprotokoll', k)
return {
datum: k.datum,
art: k.art,
mitBetreuter: k.mitBetreuter,
dauerMinuten: k.dauerMinuten,
gespraechspartner: k.gespraechspartner,
wuensche: offen.wuensche,
}
}),
gesundheit: gesundheit.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
art: e.art,
titel: e.titel,
einrichtung: e.einrichtung,
abwesenheitstage: e.abwesenheitstage,
})),
vorgaenge: vorgaenge.map((v) => ({
titel: v.titel,
kategorie: v.kategorie,
status: v.status,
naechsterSchritt: v.naechsterSchritt,
gegenstelle: v.gegenstelle,
genehmigungspflichtig: v.genehmigungspflichtig,
erledigtAm: v.erledigtAm,
})),
}
}
/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu berechnen". */
export async function jahresberichtEntwurfAusDatenbank(
betreuteId: string,
zeitraumVon: Date,
zeitraumBis: Date,
): Promise<string> {
return jahresberichtEntwurfText(
await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
)
}
// -----------------------------------------------------------------------------
// PDF
// -----------------------------------------------------------------------------
const stile = StyleSheet.create({
ueberschrift: {
fontSize: 12,
fontFamily: 'Helvetica-Bold',
marginTop: 14,
marginBottom: 4,
},
absatz: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 6,
textAlign: 'justify',
},
aufzaehlung: {
fontSize: 10,
lineHeight: 1.5,
marginBottom: 3,
marginLeft: 12,
},
vorspann: {
fontSize: 9,
lineHeight: 1.4,
marginBottom: 10,
color: '#444444',
},
})
/**
* Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
* fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
* Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
* das sich im Textfeld auch von Hand fortschreiben laesst.
*/
export function JahresberichtInhalt({ text }: { text: string }) {
const abschnitte = textZuAbschnitten(text)
return (
<View>
{abschnitte.map((abschnitt, i) => (
<View key={`abschnitt-${i}`} wrap={true}>
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
{abschnitt.absaetze.map((absatz, j) => (
<Text
key={`absatz-${i}-${j}`}
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
>
{absatz}
</Text>
))}
</View>
))}
</View>
)
}
export function JahresberichtDokument({
kopf,
text,
vorspann,
}: {
kopf: Berichtskopf
text: string
vorspann?: string
}) {
return (
<Document title={kopf.titel} author={kopf.erstelltVon} language="de">
<Berichtsseite kopf={kopf} mitUnterschrift={true}>
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
<JahresberichtInhalt text={text} />
</Berichtsseite>
</Document>
)
}
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
return pdfErzeugen(<JahresberichtDokument kopf={kopf} text={text} />)
}
+26 -52
View File
@@ -1,18 +1,21 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import type { BerichtArt } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { sealen, entschluesselnWennMoeglich } from '@/lib/crypto/feld' import { sealen, entschluesselnWennMoeglich } from '@/lib/crypto/feld'
import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung' import { erfordereFrischeAnmeldung, erfordereZugang } from '@/lib/auth/sitzung'
import { ablegen } from '@/lib/speicher' import { ablegen } from '@/lib/speicher'
import { belegmappeErzeugen } from '@/lib/dokumente/belegmappe' import {
belegmappeErzeugen,
type Belegmappenauswahl,
type Mappenergebnis,
} from '@/lib/dokumente/belegmappe'
import type { Berichtskopf } from '@/lib/pdf/rahmen' import type { Berichtskopf } from '@/lib/pdf/rahmen'
import { import {
jahresberichtEntwurfAusDatenbank, jahresberichtEntwurfAusDatenbank,
jahresberichtPdf, jahresberichtPdf,
} from '@/lib/berichte/jahresbericht.tsx' } from '@/lib/berichte/jahresbericht'
import { docxErzeugen } from '@/lib/berichte/docx' import { docxErzeugen } from '@/lib/berichte/docx'
import { unterlagenpaketErzeugen } from '@/lib/berichte/unterlagenpaket' import { unterlagenpaketErzeugen } from '@/lib/berichte/unterlagenpaket'
import { import {
@@ -391,19 +394,15 @@ export async function belegmappeAnlegen(
const { benutzer, betreute, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben') const { benutzer, betreute, zugang } = await erfordereZugang(betreuteId, 'BERICHTE', 'schreiben')
if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Belegmappe erzeugen.' } if (zugang.rolle === 'MITLESER') return { fehler: 'Als Mitleser darfst du keine Belegmappe erzeugen.' }
const erstelltAm = new Date() // Die Auswahl wird in die Form gebracht, die das Dokumentenmodul erwartet.
const auswahl: Belegmappenauswahl =
quelle === 'zeitraum'
? { art: 'zeitraum', von: zeitraumVon!, bis: zeitraumBis! }
: { art: 'vorgang', vorgangId: vorgangId! }
let ergebnis: { pdf: Buffer; dateiname: string; anzahlBelege: number } let ergebnis: Mappenergebnis
try { try {
ergebnis = await belegmappeErzeugen({ ergebnis = await belegmappeErzeugen(betreuteId, auswahl, benutzer.name)
betreuteId,
zeitraumVon: quelle === 'zeitraum' ? zeitraumVon : null,
zeitraumBis: quelle === 'zeitraum' ? zeitraumBis : null,
vorgangId: quelle === 'vorgang' ? (vorgangId ?? null) : null,
titel: titel && titel !== '' ? titel : 'Belegmappe',
erstelltVon: benutzer.name,
erstelltAm,
})
} catch (fehler) { } catch (fehler) {
return { return {
fehler: fehler:
@@ -412,7 +411,7 @@ export async function belegmappeAnlegen(
} }
} }
if (ergebnis.anzahlBelege === 0) { if (ergebnis.anzahl === 0) {
return { fehler: 'Zu dieser Auswahl gibt es keine Belege. Wähle einen anderen Zeitraum oder Vorgang.' } return { fehler: 'Zu dieser Auswahl gibt es keine Belege. Wähle einen anderen Zeitraum oder Vorgang.' }
} }
@@ -441,7 +440,7 @@ export async function belegmappeAnlegen(
aktion: 'ERSTELLT', aktion: 'ERSTELLT',
entitaet: 'Bericht', entitaet: 'Bericht',
entitaetId: bericht.id, entitaetId: bericht.id,
bezeichnung: `${bericht.titel} (${ergebnis.anzahlBelege} Belege, ${betreute.nachname})`, bezeichnung: `${bericht.titel} (${ergebnis.anzahl} Belege, ${betreute.nachname})`,
betreuteId, betreuteId,
userId: benutzer.id, userId: benutzer.id,
userEmail: benutzer.email, userEmail: benutzer.email,
@@ -449,7 +448,14 @@ export async function belegmappeAnlegen(
}) })
revalidatePath('/berichte') revalidatePath('/berichte')
return { berichtId: bericht.id, erfolg: `Belegmappe mit ${ergebnis.anzahlBelege} Belegen erzeugt.` }
const hinweise =
ergebnis.hinweise.length > 0 ? ` Nicht aufgenommen: ${ergebnis.hinweise.join(' ')}` : ''
return {
berichtId: bericht.id,
erfolg: `Belegmappe mit ${ergebnis.anzahl} Belegen erzeugt.${hinweise}`,
}
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -586,9 +592,9 @@ export async function berichtLoeschen(
/** /**
* Der Export selbst laeuft ueber die Route unter /berichte/export/datei, * Der Export selbst laeuft ueber die Route unter /berichte/export/datei,
* weil ein ZIP als Strom ausgeliefert wird und eine Server Action keinen * weil ein ZIP als Strom ausgeliefert wird und eine Server Action keinen
* Strom zurueckgeben kann. Diese Aktion sorgt fuer die frische Anmeldung * Strom zurueckgeben kann. Diese Aktion erzwingt vorher die frische
* und schreibt Protokoll und Bericht-Datensatz - damit spaeter nachweisbar * Anmeldung; Protokolleintrag und Bericht-Datensatz schreibt die Route,
* ist, wann Daten das Haus verlassen haben. * sobald der Strom wirklich beginnt.
*/ */
export async function exportVorbereiten( export async function exportVorbereiten(
_zustand: Aktionszustand, _zustand: Aktionszustand,
@@ -604,35 +610,3 @@ export async function exportVorbereiten(
await erfordereFrischeAnmeldung(benutzer, '/berichte/export') await erfordereFrischeAnmeldung(benutzer, '/berichte/export')
return { erfolg: 'Bestätigt. Der Export kann jetzt geladen werden.' } return { erfolg: 'Bestätigt. Der Export kann jetzt geladen werden.' }
} }
/** Vermerkt einen erfolgten Export - aufgerufen von der Downloadroute. */
export async function exportProtokollieren(angaben: {
betreuteId: string
dateiname: string
art?: BerichtArt
}): Promise<void> {
const { benutzer } = await erfordereZugang(angaben.betreuteId, 'BERICHTE', 'schreiben')
const bericht = await db.bericht.create({
data: {
betreuteId: angaben.betreuteId,
art: angaben.art ?? 'VOLLEXPORT',
titel: 'Vollexport aller Daten',
stichtag: new Date(),
dateiname: angaben.dateiname,
erstelltVonId: benutzer.id,
},
})
await protokollieren({
aktion: 'EXPORTIERT',
entitaet: 'Betreute',
entitaetId: angaben.betreuteId,
bezeichnung: `Vollexport ${angaben.dateiname}`,
betreuteId: angaben.betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: { export: { alt: null, neu: bericht.id } },
})
}
+5 -5
View File
@@ -282,7 +282,7 @@ export async function freigabenSpeichern(
const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } }) const ziel = await db.user.findFirst({ where: { id: userId, ...nichtGeloescht } })
if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' } if (!ziel) return { fehler: 'Diesen Benutzer gibt es nicht (mehr).' }
const module = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[]) const freigaben = freigabenNormalisieren(rolle, gelesen.data.freigegebeneModule as Modul[])
const vorher = await db.betreuungsZugang.findUnique({ const vorher = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } }, where: { userId_betreuteId: { userId, betreuteId } },
@@ -291,13 +291,13 @@ export async function freigabenSpeichern(
const aenderung = freigabeAenderung( const aenderung = freigabeAenderung(
vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] }, vorher ?? { rolle: 'MITLESER', freigegebeneModule: [] },
{ rolle, freigegebeneModule: module }, { rolle, freigegebeneModule: freigaben },
) )
await db.betreuungsZugang.upsert({ await db.betreuungsZugang.upsert({
where: { userId_betreuteId: { userId, betreuteId } }, where: { userId_betreuteId: { userId, betreuteId } },
create: { userId, betreuteId, rolle, freigegebeneModule: module }, create: { userId, betreuteId, rolle, freigegebeneModule: freigaben },
update: { rolle, freigegebeneModule: module }, update: { rolle, freigegebeneModule: freigaben },
}) })
// Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens // Wer Rechte verliert, soll sie nicht bis zum Ablauf seines Tokens
@@ -331,7 +331,7 @@ export async function freigabenSpeichern(
userName: benutzer.name, userName: benutzer.name,
aenderung: { aenderung: {
rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher }, rolle: { alt: aenderung.rolleVorher, neu: aenderung.rolleNachher },
freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: module }, freigegebeneModule: { alt: vorher?.freigegebeneModule ?? [], neu: freigaben },
}, },
}) })
+37 -9
View File
@@ -165,6 +165,36 @@ export async function papierkorbLaden(betreuteId: string): Promise<
return gruppen return gruppen
} }
/**
* Haengt an diesem Datensatz eine Datei im Objektspeicher?
*
* Die beiden Faelle werden ausdruecklich getrennt abgefragt statt ueber
* eine gemeinsam gehaltene Tabellenvariable: Prisma gibt jedem Modell
* eigene Signaturen, und eine Variable, die mal das eine und mal das
* andere Delegate haelt, laesst sich nicht mehr aufrufen.
*/
async function objektSchluesselFinden(
entitaet: string,
entitaetId: string,
betreuteId: string,
): Promise<string | null> {
if (entitaet === 'Dokument') {
const dokument = await db.dokument.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return dokument?.objektSchluessel ?? null
}
if (entitaet === 'Bericht') {
const bericht = await db.bericht.findFirst({
where: { id: entitaetId, betreuteId },
select: { objektSchluessel: true },
})
return bericht?.objektSchluessel ?? null
}
return null
}
export async function wiederherstellen( export async function wiederherstellen(
_zustand: Aktionszustand, _zustand: Aktionszustand,
formular: FormData, formular: FormData,
@@ -224,20 +254,18 @@ export async function endgueltigLoeschen(
// Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst // Bei Dokumenten und Berichten haengt eine Datei daran. Sie wird zuerst
// entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten, // entfernt: Bliebe sie liegen, waere sie eine verwaiste Kopie von Daten,
// die ausdruecklich verschwinden sollten. // die ausdruecklich verschwinden sollten.
if (gelesen.data.entitaet === 'Dokument' || gelesen.data.entitaet === 'Bericht') { const schluessel = await objektSchluesselFinden(
const tabelle = gelesen.data.entitaet === 'Dokument' ? db.dokument : db.bericht gelesen.data.entitaet,
const datensatz = await tabelle.findFirst({ gelesen.data.entitaetId,
where: { id: gelesen.data.entitaetId, betreuteId: gelesen.data.betreuteId }, gelesen.data.betreuteId,
select: { objektSchluessel: true }, )
}) if (schluessel) {
if (datensatz?.objektSchluessel) {
try { try {
await entfernen(datensatz.objektSchluessel) await entfernen(schluessel)
} catch (fehler) { } catch (fehler) {
console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler) console.error('[papierkorb] Datei konnte nicht entfernt werden:', fehler)
} }
} }
}
await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId) await art.endgueltigLoeschen(gelesen.data.entitaetId, gelesen.data.betreuteId)
+1 -1
View File
@@ -14,7 +14,7 @@ import {
offeneErgaenzungen, offeneErgaenzungen,
textZuAbschnitten, textZuAbschnitten,
type JahresberichtQuellen, type JahresberichtQuellen,
} from '@/lib/berichte/jahresbericht' } from '@/lib/berichte/jahresbericht.entwurf'
import { import {
berichtDateiname, berichtDateiname,