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