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