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:
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user