feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+264
View File
@@ -0,0 +1,264 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { CalendarClock, FolderKanban, Link2, Pencil, Repeat, Scale } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ErledigenKnopf } from '@/components/fristen/erledigen-knopf'
import { FristAktionen } from '@/components/fristen/frist-aktionen'
import { FristFormular } from '@/components/fristen/frist-formular'
import { alsAnzeige } from '@/components/fristen/typen'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import { ausIsoTag, nichtWerktagHinweis, wochentagName } from '@/lib/fristen/datum'
import { fristenKontext } from '@/lib/fristen/kontext'
import { rruleBeschreiben } from '@/lib/fristen/wiederholung'
import { FRIST_STATUS_BESCHRIFTUNG, FRIST_TYP_BESCHRIFTUNG } from '@/lib/validierung/fristen'
import { formatDatum } from '@/lib/utils'
import { fristAendern } from '@/server/actions/fristen'
/**
* Eine einzelne Frist.
*
* Das Bearbeitungsformular liegt auf derselben Adresse hinter
* "?bearbeiten=1" statt auf einer eigenen Unterseite: So bleibt der Weg
* zurueck zur Ansicht der Zurueck-Knopf des Browsers.
*/
export default async function FristSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { id } = await params
const parameter = await searchParams
const { betreute, darfSchreiben } = await fristenKontext('lesen')
const datensatz = await db.frist.findFirst({
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
include: {
vorgang: { select: { id: true, titel: true } },
vorgaengerFrist: { select: { id: true, titel: true, datum: true, status: true } },
nachfolgerFrist: { select: { id: true, titel: true, datum: true, status: true } },
},
})
if (!datensatz) notFound()
const frist = alsAnzeige(datensatz)
const datum = ausIsoTag(frist.datum)!
const ampel = fristAmpel(datum, frist.status)
const werktagshinweis = frist.status === 'OFFEN' ? nichtWerktagHinweis(datum) : null
const bearbeiten =
darfSchreiben &&
(Array.isArray(parameter.bearbeiten) ? parameter.bearbeiten[0] : parameter.bearbeiten) === '1'
const vorgaenge = bearbeiten
? await db.vorgang.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { aktualisiertAm: 'desc' },
take: 100,
})
: []
return (
<div className="space-y-6">
<SeitenKopf
titel={frist.titel}
beschreibung={
<span className="inline-flex flex-wrap items-center gap-2">
<Badge variant={ampel.variante}>{ampelText(ampel, frist.ausschlussfrist)}</Badge>
<Badge variant="outline">{FRIST_TYP_BESCHRIFTUNG[frist.typ]}</Badge>
<Badge variant="secondary">{FRIST_STATUS_BESCHRIFTUNG[frist.status]}</Badge>
</span>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: frist.titel },
]}
aktionen={
darfSchreiben && !bearbeiten ? (
<Button variant="outline" asChild>
<Link href={`/fristen/${frist.id}?bearbeiten=1`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
) : undefined
}
/>
{frist.ausschlussfrist && frist.status === 'OFFEN' && (
<Alert variant="fehler">
<AlertTitle>Ausschlussfrist</AlertTitle>
<AlertDescription>
Läuft dieser Termin ab, ist der Anspruch ersatzlos verloren. Es gibt keine
Nachsicht und keine Wiedereinsetzung — auch nicht bei guten Gründen.
</AlertDescription>
</Alert>
)}
{bearbeiten ? (
<FristFormular
betreuteId={betreute.id}
aktion={fristAendern}
frist={frist}
vorgaenge={vorgaenge}
/>
) : (
<>
<dl className="grid gap-4 rounded-lg border bg-card p-5 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">Stichtag</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<CalendarClock className="size-4 shrink-0" aria-hidden="true" />
{wochentagName(datum)}, {formatDatum(datum)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Vorwarnung</dt>
<dd className="mt-0.5 font-medium">
{frist.vorwarnungTage.length === 0
? 'Keine'
: frist.vorwarnungTage.map((t) => (t === 1 ? '1 Tag' : `${t} Tage`)).join(', ') +
' vorher'}
</dd>
</div>
{frist.rechtsgrundlage && (
<div>
<dt className="text-sm text-muted-foreground">Rechtsgrundlage</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<Scale className="size-4 shrink-0" aria-hidden="true" />
{frist.rechtsgrundlage}
</dd>
</div>
)}
<div>
<dt className="text-sm text-muted-foreground">Wiederholung</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<Repeat className="size-4 shrink-0" aria-hidden="true" />
{rruleBeschreiben(frist.wiederholungRrule)}
</dd>
</div>
{frist.vorgang && (
<div>
<dt className="text-sm text-muted-foreground">Gehört zum Vorgang</dt>
<dd className="mt-0.5">
<Link
href={`/vorgaenge/${frist.vorgang.id}`}
className="inline-flex items-center gap-2 font-medium hover:underline"
>
<FolderKanban className="size-4 shrink-0" aria-hidden="true" />
{frist.vorgang.titel}
</Link>
</dd>
</div>
)}
{frist.erledigtAm && (
<div>
<dt className="text-sm text-muted-foreground">Erledigt am</dt>
<dd className="mt-0.5 font-medium">{formatDatum(ausIsoTag(frist.erledigtAm))}</dd>
</div>
)}
{frist.systemSchluessel && (
<div className="sm:col-span-2">
<dt className="text-sm text-muted-foreground">Abgeleitete Pflichtfrist</dt>
<dd className="mt-0.5 text-sm">
Diese Frist wird aus dem Bestellungsdatum berechnet (Schlüssel{' '}
<code className="rounded bg-muted px-1 py-0.5">{frist.systemSchluessel}</code>).
Ändert sich das Bestellungsdatum, zieht die Anwendung das Datum nach — solange
die Frist noch offen ist.
</dd>
</div>
)}
</dl>
{werktagshinweis && (
<Alert variant="warnung">
<AlertTitle>Stichtag an einem arbeitsfreien Tag</AlertTitle>
<AlertDescription>{werktagshinweis}</AlertDescription>
</Alert>
)}
{frist.beschreibung && (
<section className="rounded-lg border bg-card p-5">
<h2 className="text-base font-semibold">Was ist zu tun?</h2>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed">
{frist.beschreibung}
</p>
</section>
)}
{(datensatz.vorgaengerFrist || datensatz.nachfolgerFrist) && (
<section className="rounded-lg border bg-card p-5">
<h2 className="flex items-center gap-2 text-base font-semibold">
<Link2 className="size-4" aria-hidden="true" />
Fristkette
</h2>
<ul className="mt-2 space-y-1 text-sm">
{datensatz.vorgaengerFrist && (
<li>
Hervorgegangen aus{' '}
<Link
href={`/fristen/${datensatz.vorgaengerFrist.id}`}
className="font-medium underline"
>
{datensatz.vorgaengerFrist.titel}
</Link>{' '}
vom {formatDatum(datensatz.vorgaengerFrist.datum)}.
</li>
)}
{datensatz.nachfolgerFrist && (
<li>
Nachfolger:{' '}
<Link
href={`/fristen/${datensatz.nachfolgerFrist.id}`}
className="font-medium underline"
>
{datensatz.nachfolgerFrist.titel}
</Link>{' '}
am {formatDatum(datensatz.nachfolgerFrist.datum)}.
</li>
)}
</ul>
</section>
)}
{darfSchreiben && (
<section className="space-y-4 rounded-lg border bg-card p-5">
<h2 className="text-base font-semibold">Was möchtest du tun?</h2>
{frist.status === 'OFFEN' && (
<ErledigenKnopf
betreuteId={betreute.id}
fristId={frist.id}
titel={frist.titel}
wiederkehrend={Boolean(frist.wiederholungRrule)}
groesse="default"
/>
)}
<FristAktionen
betreuteId={betreute.id}
fristId={frist.id}
datum={frist.datum}
ausschlussfrist={frist.ausschlussfrist}
/>
</section>
)}
</>
)}
{frist.rechtsgrundlage && <Rechtshinweis rechtsgrundlage={frist.rechtsgrundlage} />}
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import Link from 'next/link'
import { List, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { KalenderAbo } from '@/components/fristen/kalender-abo'
import { KalenderMonat, monatsname } from '@/components/fristen/kalender-monat'
import { alsAnzeige } from '@/components/fristen/typen'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag, plusTage, tag } from '@/lib/fristen/datum'
import { feedUrlErzeugen } from '@/lib/fristen/ics'
import { fristenKontext } from '@/lib/fristen/kontext'
export const metadata = { title: 'Fristenkalender' }
/**
* Monatsansicht.
*
* Geladen wird ein Puffer von einer Woche vor und nach dem Monat: Das
* Raster zeigt am Rand Tage der Nachbarmonate, und dort sollen keine
* Fristen unsichtbar bleiben.
*/
export default async function KalenderSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { betreute, benutzer, darfSchreiben } = await fristenKontext('lesen')
const parameter = await searchParams
const einzeln = (name: string): string => {
const wert = parameter[name]
return (Array.isArray(wert) ? wert[0] : wert) ?? ''
}
const heute = heuteAlsTag()
const jahr = ganzzahl(einzeln('jahr'), heute.getUTCFullYear(), 1900, 2200)
const monat = ganzzahl(einzeln('monat'), heute.getUTCMonth() + 1, 1, 12)
const monatsanfang = tag(jahr, monat, 1)
const monatsende = plusTage(tag(monat === 12 ? jahr + 1 : jahr, monat === 12 ? 1 : monat + 1, 1), -1)
const datensaetze = await db.frist.findMany({
where: {
betreuteId: betreute.id,
...nichtGeloescht,
datum: { gte: plusTage(monatsanfang, -7), lte: plusTage(monatsende, 7) },
},
orderBy: [{ datum: 'asc' }, { ausschlussfrist: 'desc' }],
include: { vorgang: { select: { id: true, titel: true } } },
})
const feedUrl = feedUrlErzeugen(betreute.id, benutzer.id)
return (
<div className="space-y-6">
<SeitenKopf
titel="Fristenkalender"
beschreibung={`Alle Termine im Überblick. Am Handy als Tagesliste, am Bildschirm als Monat.`}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: 'Kalender' },
]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/fristen">
<List aria-hidden="true" />
Liste
</Link>
</Button>
{darfSchreiben && (
<Button asChild>
<Link href="/fristen/neu">
<Plus aria-hidden="true" />
Frist anlegen
</Link>
</Button>
)}
</>
}
/>
{(jahr !== heute.getUTCFullYear() || monat !== heute.getUTCMonth() + 1) && (
<div>
<Button variant="ghost" size="sm" asChild>
<Link href="/fristen/kalender">
Zurück zum {monatsname(heute.getUTCMonth() + 1)} {heute.getUTCFullYear()}
</Link>
</Button>
</div>
)}
<KalenderMonat jahr={jahr} monat={monat} fristen={datensaetze.map(alsAnzeige)} />
<KalenderAbo feedUrl={feedUrl} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
function ganzzahl(text: string, standard: number, min: number, max: number): number {
const zahl = Number.parseInt(text, 10)
if (Number.isNaN(zahl) || zahl < min || zahl > max) return standard
return zahl
}
+41
View File
@@ -0,0 +1,41 @@
import { FristFormular } from '@/components/fristen/frist-formular'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { fristenKontext } from '@/lib/fristen/kontext'
import { fristAnlegen } from '@/server/actions/fristen'
export const metadata = { title: 'Frist anlegen' }
/**
* Neue Frist.
*
* Der Schreibzugriff wird hier geprueft und in der Server Action noch
* einmal - die Seite auszublenden reicht nicht, ein Formular laesst sich
* auch von aussen abschicken.
*/
export default async function NeueFristSeite() {
const { betreute } = await fristenKontext('schreiben')
const vorgaenge = await db.vorgang.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht, erledigtAm: null },
select: { id: true, titel: true },
orderBy: { aktualisiertAm: 'desc' },
take: 100,
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Frist anlegen"
beschreibung="Für alles, was einen Stichtag hat: Widerspruchsfristen aus Bescheiden, Wiedervorlagen, eigene Vorhaben."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: 'Neu' },
]}
/>
<FristFormular betreuteId={betreute.id} aktion={fristAnlegen} vorgaenge={vorgaenge} />
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import Link from 'next/link'
import type { Prisma } from '@prisma/client'
import { CalendarDays, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Filterleiste } from '@/components/fristen/filterleiste'
import { Fristenliste } from '@/components/fristen/fristenliste'
import { PflichtfristenKnopf } from '@/components/fristen/pflichtfristen-knopf'
import { alsAnzeige } from '@/components/fristen/typen'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag, plusJahre, plusTage } from '@/lib/fristen/datum'
import { fristenKontext } from '@/lib/fristen/kontext'
import { FRIST_STATUS, FRIST_TYPEN } from '@/lib/validierung/fristen'
export const metadata = { title: 'Fristen' }
/**
* Die Fristenliste.
*
* Standardansicht ist "alles Offene", gruppiert nach Dringlichkeit. Wer die
* Seite morgens oeffnet, soll oben sehen, was brennt - nicht eine nach
* Datum sortierte Tabelle, in der das Ueberfaellige zwischen dem Erledigten
* verschwindet.
*/
export default async function FristenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { betreute, benutzer, darfSchreiben } = await fristenKontext('lesen')
const parameter = await searchParams
const einzeln = (name: string): string => {
const wert = parameter[name]
return (Array.isArray(wert) ? wert[0] : wert) ?? ''
}
const zeitraum = einzeln('zeitraum') || 'offen'
const kategorie = einzeln('kategorie')
const typ = einzeln('typ')
const status = einzeln('status')
const heute = heuteAlsTag()
const wo: Prisma.FristWhereInput = { betreuteId: betreute.id, ...nichtGeloescht }
// --- Zeitraum -------------------------------------------------------------
switch (zeitraum) {
case 'ueberfaellig':
wo.status = 'OFFEN'
wo.datum = { lt: heute }
break
case 'woche':
wo.status = 'OFFEN'
wo.datum = { lte: plusTage(heute, 7) }
break
case 'monat':
wo.status = 'OFFEN'
wo.datum = { lte: plusTage(heute, 31) }
break
case 'jahr':
wo.status = 'OFFEN'
wo.datum = { lte: plusJahre(heute, 1) }
break
case 'alle':
break
default:
// "offen" ist der Standard: alles, was noch zu tun ist.
wo.status = 'OFFEN'
}
// Ein ausdruecklich gewaehlter Status schlaegt die Voreinstellung des
// Zeitraums - sonst liesse sich "nur Entfallenes" nie anzeigen.
if (status && (FRIST_STATUS as readonly string[]).includes(status)) {
wo.status = status as (typeof FRIST_STATUS)[number]
}
if (typ && (FRIST_TYPEN as readonly string[]).includes(typ)) {
wo.typ = typ as (typeof FRIST_TYPEN)[number]
}
// --- Kategorie ------------------------------------------------------------
switch (kategorie) {
case 'pflicht':
wo.systemSchluessel = { not: null }
break
case 'eigene':
wo.systemSchluessel = null
break
case 'ausschluss':
wo.ausschlussfrist = true
break
case 'wiederkehrend':
wo.wiederholungRrule = { not: null }
break
default:
break
}
const [datensaetze, gesamtOffen, ueberfaellig] = await Promise.all([
db.frist.findMany({
where: wo,
orderBy: [{ datum: 'asc' }],
include: { vorgang: { select: { id: true, titel: true } } },
take: 500,
}),
db.frist.count({ where: { betreuteId: betreute.id, status: 'OFFEN', ...nichtGeloescht } }),
db.frist.count({
where: {
betreuteId: betreute.id,
status: 'OFFEN',
datum: { lt: heute },
...nichtGeloescht,
},
}),
])
const fristen = datensaetze.map(alsAnzeige)
const verfallsgefahr = fristen.filter(
(f) => f.ausschlussfrist && f.status === 'OFFEN' && f.datum <= alsIsoTag(plusTage(heute, 90)),
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Fristen"
beschreibung={
gesamtOffen === 0
? 'Hier stehen alle Termine, die eingehalten werden müssen — gesetzliche wie selbst gesetzte.'
: `${gesamtOffen} offene Frist${gesamtOffen === 1 ? '' : 'en'}` +
(ueberfaellig > 0 ? `, davon ${ueberfaellig} überfällig.` : '.')
}
pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Fristen' }]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/fristen/kalender">
<CalendarDays aria-hidden="true" />
Kalender
</Link>
</Button>
{darfSchreiben && (
<Button asChild>
<Link href="/fristen/neu">
<Plus aria-hidden="true" />
Frist anlegen
</Link>
</Button>
)}
</>
}
/>
{verfallsgefahr.length > 0 && (
<Alert variant="fehler">
<AlertTitle>
{verfallsgefahr.length === 1
? 'Eine Ausschlussfrist läuft'
: `${verfallsgefahr.length} Ausschlussfristen laufen`}
</AlertTitle>
<AlertDescription>
<p>
Läuft eine Ausschlussfrist ab, ist der Anspruch ersatzlos weg. Bei der
Aufwandspauschale nach § 1878 BGB ist damit die Vergütung für ein ganzes
Betreuungsjahr verloren — ohne Nachsicht, ohne Wiedereinsetzung.
</p>
<ul className="mt-2 list-inside list-disc">
{verfallsgefahr.map((f) => (
<li key={f.id}>
<Link href={`/fristen/${f.id}`} className="underline">
{f.titel}
</Link>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Filterleiste />
{fristen.length === 0 ? (
<Leerzustand
titel={
gesamtOffen === 0 ? 'Noch keine Fristen erfasst' : 'Zu diesem Filter passt nichts'
}
beschreibung={
gesamtOffen === 0
? betreute.bestellungAm
? 'Leite zuerst die gesetzlichen Pflichtfristen aus dem Bestellungsdatum ab — Anfangsbericht, Vermögensverzeichnis, Jahresbericht und die Ausschlussfrist für die Aufwandspauschale.'
: 'Trag zuerst das Bestellungsdatum in den Stammdaten ein. Daraus ergeben sich fast alle gesetzlichen Fristen.'
: 'Setz den Filter zurück, um alle Fristen zu sehen.'
}
aktion={
darfSchreiben && gesamtOffen === 0 && betreute.bestellungAm ? (
<PflichtfristenKnopf betreuteId={betreute.id} variante="default" />
) : (
<Button variant="outline" asChild>
<Link href="/fristen">Alle Fristen anzeigen</Link>
</Button>
)
}
/>
) : (
<Fristenliste fristen={fristen} darfSchreiben={darfSchreiben} />
)}
{darfSchreiben && fristen.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="text-base font-semibold">Gesetzliche Pflichtfristen</h2>
<p className="mt-1 text-sm text-muted-foreground">
Berechnet Anfangsbericht, Vermögensverzeichnis, Jahresbericht, Vermögensübersicht
und die Ausschlussfrist für die Aufwandspauschale neu aus dem Bestellungsdatum
{betreute.bestellungAm ? '' : ' — das in den Stammdaten noch fehlt'}. Mehrfaches
Ableiten legt nichts doppelt an.
</p>
<div className="mt-3">
<PflichtfristenKnopf betreuteId={betreute.id} />
</div>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 1863 BGB, § 1865 BGB, § 1878 BGB" />
<p className="sr-only">
Angemeldet als {benutzer.name}. Fristen der betreuten Person {betreute.vorname}{' '}
{betreute.nachname}.
</p>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { BelegeKachel } from '@/components/dashboard/belege-kachel'
import { FristenAmpel } from '@/components/dashboard/fristen-ampel'
import { Kennzahlen } from '@/components/dashboard/kennzahlen'
import { NachforderungenKachel } from '@/components/dashboard/nachforderungen-kachel'
import { PosteingangKachel } from '@/components/dashboard/posteingang-kachel'
import { WartetAufAndere } from '@/components/dashboard/wartet-auf-andere'
import { darf } from '@/lib/auth/rollen'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { dashboardLaden } from '@/lib/dashboard/laden'
export const metadata: Metadata = { title: 'Heute' }
/**
* Das Dashboard.
*
* Es beantwortet genau eine Frage: was muss ich heute tun? Deshalb endet
* jede Kachel in einer Handlung, und die Reihenfolge ist Dringlichkeit,
* nicht fachliche Systematik. Am Handy einspaltig von oben nach unten, am
* Schreibtisch zweispaltig - die Fristen-Ampel steht immer zuoberst.
*/
export default async function HeutePage() {
const benutzer = await erfordereBenutzer('/heute')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
// Das Dashboard fasst mehrere Module zusammen. Der Zugang wird gegen die
// Stammdaten geprueft; jede einzelne Kachel entfaellt, wenn ihr Modul
// nicht freigegeben ist.
const { betreute, zugang } = await erfordereZugang(aktive.id, 'STAMMDATEN', 'lesen')
const daten = await dashboardLaden(betreute, (modul) => darf(zugang, modul, 'lesen'))
const heuteText = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(daten.heute)
return (
<div className="space-y-6">
<SeitenKopf
titel="Heute"
beschreibung={
<>
{heuteText} · Betreuung für {daten.name}
</>
}
aktionen={
<>
<Button asChild variant="outline">
<Link href="/kontakte/protokoll/neu">Kontakt festhalten</Link>
</Button>
<Button asChild>
<Link href="/dokumente/neu?quelle=kamera">Beleg erfassen</Link>
</Button>
</>
}
/>
{/* Die Ampel steht immer oben - in jeder Bildschirmbreite. */}
<FristenAmpel daten={daten.fristen} />
{/*
Am Handy laeuft alles einspaltig in der Reihenfolge der
Dringlichkeit: Nachforderungen (Geld und Fristen des Traegers),
dann Vorgaenge, dann Post, dann Belege.
*/}
<div className="grid gap-4 lg:grid-cols-2">
<NachforderungenKachel daten={daten.nachforderungen} />
<WartetAufAndere daten={daten.wartet} />
<PosteingangKachel daten={daten.posteingang} />
<BelegeKachel daten={daten.belege} />
</div>
<Kennzahlen daten={daten.kennzahlen} />
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation'
/**
* Die Wurzel der Anwendung hat keinen eigenen Inhalt: Wer angemeldet ist,
* will wissen, was heute zu tun ist.
*/
export default function StartPage() {
redirect('/heute')
}
+209
View File
@@ -0,0 +1,209 @@
import type { Metadata } from 'next'
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { entschluesselnWennMoeglich } from '@/lib/crypto/feld'
import { formatDatumZeit } from '@/lib/utils'
import type { AuditAktion } from '@prisma/client'
export const metadata: Metadata = { title: 'Protokoll' }
export const dynamic = 'force-dynamic'
const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
ERSTELLT: 'angelegt',
GEAENDERT: 'geändert',
GELOESCHT: 'in den Papierkorb',
WIEDERHERGESTELLT: 'wiederhergestellt',
ENDGUELTIG_GELOESCHT: 'endgültig gelöscht',
ANGESEHEN: 'angesehen',
EXPORTIERT: 'exportiert',
DOKUMENT_HERUNTERGELADEN: 'Dokument geöffnet',
ANMELDUNG: 'angemeldet',
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
ABMELDUNG: 'abgemeldet',
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
PASSWORT_GEAENDERT: 'Passwort geändert',
ROLLE_GEAENDERT: 'Rolle geändert',
FREIGABE_GEAENDERT: 'Freigabe geändert',
}
function variante(aktion: AuditAktion) {
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange'
if (aktion === 'ERSTELLT') return 'gruen'
return 'secondary'
}
type Aenderung = Record<string, { alt: unknown; neu: unknown }>
function alsText(wert: unknown): string {
if (wert === null || wert === undefined || wert === '') return '—'
if (typeof wert === 'boolean') return wert ? 'ja' : 'nein'
if (Array.isArray(wert)) return wert.length ? wert.join(', ') : '—'
return String(wert)
}
export default async function ProtokollSeite({
searchParams,
}: {
searchParams: Promise<{ seite?: string }>
}) {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
await erfordereZugang(aktive.id, 'STAMMDATEN')
const { seite = '1' } = await searchParams
const proSeite = 50
const nummer = Math.max(1, Number.parseInt(seite, 10) || 1)
const [eintraege, gesamt] = await Promise.all([
db.auditLog.findMany({
where: { betreuteId: aktive.id },
orderBy: { zeitpunkt: 'desc' },
skip: (nummer - 1) * proSeite,
take: proSeite,
}),
db.auditLog.count({ where: { betreuteId: aktive.id } }),
])
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
return (
<div className="space-y-6">
<SeitenKopf
titel="Protokoll"
beschreibung="Jede Änderung an den Daten dieser Betreuung, lückenlos und in der Reihenfolge, in der sie geschehen ist."
/>
<Alert variant="hinweis">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Unveränderlich — und das ist Absicht</AlertTitle>
<AlertDescription>
Dieses Protokoll kann niemand nachträglich ändern oder löschen, auch nicht mit direktem
Zugriff auf die Datenbank: ein Datenbank-Trigger weist jedes UPDATE und jedes DELETE ab.
Damit ist es dein Nachweis gegenüber dem Betreuungsgericht und gegenüber möglichen
Miterben, dass du sauber gearbeitet hast.
</AlertDescription>
</Alert>
{eintraege.length === 0 ? (
<Leerzustand
titel="Noch keine Einträge"
beschreibung="Sobald du Daten anlegst oder änderst, steht es hier."
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Zeitpunkt</TableHead>
<TableHead className="w-40">Wer</TableHead>
<TableHead className="w-40">Was</TableHead>
<TableHead>Gegenstand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eintraege.map((eintrag) => {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
let aenderung: Aenderung | null = null
try {
aenderung = roh ? (JSON.parse(roh) as Aenderung) : null
} catch {
aenderung = null
}
return (
<TableRow key={eintrag.id} className="align-top">
<TableCell className="whitespace-nowrap tabular-nums">
{formatDatumZeit(eintrag.zeitpunkt)}
</TableCell>
<TableCell>
<span className="block truncate">{eintrag.userName ?? '—'}</span>
<span className="block truncate text-xs text-muted-foreground">
{eintrag.userEmail}
</span>
</TableCell>
<TableCell>
<Badge variant={variante(eintrag.aktion)}>
{AKTION_BESCHRIFTUNG[eintrag.aktion]}
</Badge>
</TableCell>
<TableCell>
<span className="font-medium">{eintrag.entitaet}</span>
{eintrag.bezeichnung && (
<span className="text-muted-foreground"> · {eintrag.bezeichnung}</span>
)}
{aenderung && Object.keys(aenderung).length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
{Object.keys(aenderung).length} Feld
{Object.keys(aenderung).length === 1 ? '' : 'er'} geändert
</summary>
<dl className="mt-2 space-y-1.5 border-l-2 pl-3 text-sm">
{Object.entries(aenderung).map(([feld, werte]) => (
<div key={feld}>
<dt className="text-xs font-medium text-muted-foreground">
{feld}
</dt>
<dd className="flex flex-wrap items-baseline gap-1.5">
<span className="text-muted-foreground line-through">
{alsText(werte.alt)}
</span>
<span aria-hidden="true">→</span>
<span className="font-medium">{alsText(werte.neu)}</span>
</dd>
</div>
))}
</dl>
</details>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{seiten > 1 && (
<nav aria-label="Seiten" className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Seite {nummer} von {seiten} · {gesamt} Einträge
</span>
<span className="flex gap-2">
{nummer > 1 && (
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer - 1}`}>
Neuere
</a>
)}
{nummer < seiten && (
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer + 1}`}>
Ältere
</a>
)}
</span>
</nav>
)}
</div>
)
}
@@ -0,0 +1,433 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import Link from 'next/link'
import { AlertCircle, Loader2, Save } from 'lucide-react'
import { betreuteAendern, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
/** Prisma liefert Date, das Datumsfeld im Browser will "JJJJ-MM-TT". */
function alsDatumswert(datum: string | null | undefined): string {
if (!datum) return ''
return datum.slice(0, 10)
}
export type StammdatenWerte = {
id: string
vorname: string
nachname: string
geburtsname: string | null
geburtsdatum: string
geburtsort: string | null
anrede: string | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimTelefon: string | null
heimEinzugAm: string | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
krankenkasse: string | null
krankenversichertennummer: string | null
pflegekasse: string | null
pflegegrad: number | null
pflegegradSeit: string | null
pflegegradBescheidVom: string | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
diagnosen: string | null
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: string | null
bestellungBis: string | null
aufgabenkreise: string[]
aufgabenkreiseFreitext: string | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
aktenzeichenBezirk: string | null
aktenzeichenSozialamt: string | null
rentenversicherungsnummer: string | null
steuerId: string | null
notizen: string | null
}
function Speichern() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Speichern'}
</Button>
)
}
export function StammdatenFormular({ werte }: { werte: StammdatenWerte }) {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAendern, {})
/** Nach einem Fehlversuch gilt die Eingabe, sonst der gespeicherte Wert. */
const wert = (feld: keyof StammdatenWerte): string => {
const ausFehlversuch = zustand.werte?.[feld as string]
if (ausFehlversuch !== undefined) return ausFehlversuch
const gespeichert = werte[feld]
if (gespeichert === null || gespeichert === undefined) return ''
return String(gespeichert)
}
return (
<form action={aktion} className="space-y-6" noValidate>
<input type="hidden" name="betreuteId" value={werte.id} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{/* --- Person --------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Person</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="anrede" beschriftung="Anrede" fehler={zustand.felder?.anrede}>
<Input name="anrede" defaultValue={wert('anrede')} placeholder="Frau" />
</Feld>
<div className="hidden sm:block" />
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={wert('vorname')} required />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={wert('nachname')} required />
</Feld>
<Feld id="geburtsname" beschriftung="Geburtsname" fehler={zustand.felder?.geburtsname}>
<Input name="geburtsname" defaultValue={wert('geburtsname')} />
</Feld>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={alsDatumswert(wert('geburtsdatum'))}
required
/>
</Feld>
<Feld id="geburtsort" beschriftung="Geburtsort" fehler={zustand.felder?.geburtsort}>
<Input name="geburtsort" defaultValue={wert('geburtsort')} />
</Feld>
</CardContent>
</Card>
{/* --- Aufenthalt ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aufenthalt und Einrichtung</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="heimName" beschriftung="Name der Einrichtung" fehler={zustand.felder?.heimName}>
<Input name="heimName" defaultValue={wert('heimName')} />
</Feld>
<Feld id="heimZimmer" beschriftung="Zimmer" fehler={zustand.felder?.heimZimmer}>
<Input name="heimZimmer" defaultValue={wert('heimZimmer')} />
</Feld>
<Feld id="heimStrasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.heimStrasse}>
<Input name="heimStrasse" defaultValue={wert('heimStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="heimPlz" beschriftung="PLZ" fehler={zustand.felder?.heimPlz}>
<Input name="heimPlz" defaultValue={wert('heimPlz')} inputMode="numeric" />
</Feld>
<Feld id="heimOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.heimOrt}>
<Input name="heimOrt" defaultValue={wert('heimOrt')} />
</Feld>
</div>
<Feld id="heimTelefon" beschriftung="Telefon der Einrichtung" fehler={zustand.felder?.heimTelefon}>
<Input name="heimTelefon" type="tel" defaultValue={wert('heimTelefon')} />
</Feld>
<Feld id="heimEinzugAm" beschriftung="Eingezogen am" fehler={zustand.felder?.heimEinzugAm}>
<Input name="heimEinzugAm" type="date" defaultValue={alsDatumswert(wert('heimEinzugAm'))} />
</Feld>
<Feld
id="fruehereStrasse"
beschriftung="Frühere Straße"
erklaerung="Die letzte eigene Wohnanschrift. Behörden fragen danach."
fehler={zustand.felder?.fruehereStrasse}
>
<Input name="fruehereStrasse" defaultValue={wert('fruehereStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="frueherePlz" beschriftung="PLZ" fehler={zustand.felder?.frueherePlz}>
<Input name="frueherePlz" defaultValue={wert('frueherePlz')} inputMode="numeric" />
</Feld>
<Feld id="fruehererOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.fruehererOrt}>
<Input name="fruehererOrt" defaultValue={wert('fruehererOrt')} />
</Feld>
</div>
</CardContent>
</Card>
{/* --- Betreuungsgericht ---------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Betreuungsgericht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="betreuungsgericht" beschriftung="Gericht" fehler={zustand.felder?.betreuungsgericht}>
<Input name="betreuungsgericht" defaultValue={wert('betreuungsgericht')} />
</Feld>
<Feld id="aktenzeichenGericht" beschriftung="Aktenzeichen" fehler={zustand.felder?.aktenzeichenGericht}>
<Input name="aktenzeichenGericht" defaultValue={wert('aktenzeichenGericht')} />
</Feld>
<Feld
id="bestellungAm"
beschriftung="Bestellt am"
erklaerung="Steuert das Betreuungsjahr. Wird dieses Datum geändert, berechnet die Anwendung alle gesetzlichen Fristen neu."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={alsDatumswert(wert('bestellungAm'))} />
</Feld>
<Feld id="bestellungBis" beschriftung="Befristet bis" fehler={zustand.felder?.bestellungBis}>
<Input name="bestellungBis" type="date" defaultValue={alsDatumswert(wert('bestellungBis'))} />
</Feld>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<div className="grid gap-2 sm:grid-cols-2">
{AUFGABENKREISE.map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={werte.aufgabenkreise.includes(kreis)}
/>
<span className="text-sm">{AUFGABENKREIS_BESCHRIFTUNG[kreis]}</span>
</label>
))}
</div>
</fieldset>
<Feld
id="aufgabenkreiseFreitext"
beschriftung="Ergänzung zu den Aufgabenkreisen"
erklaerung="Falls die Bestellungsurkunde einen Bereich anders oder enger fasst."
fehler={zustand.felder?.aufgabenkreiseFreitext}
>
<Textarea name="aufgabenkreiseFreitext" defaultValue={wert('aufgabenkreiseFreitext')} rows={2} />
</Feld>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked={werte.ehrenamtlich} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Begründet den Anspruch auf die jährliche Aufwandspauschale.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked={werte.befreiterBetreuer} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer</span>
<span className="mt-0.5 block text-muted-foreground">
Dann genügt statt der vollständigen Rechnungslegung eine Vermögensübersicht.
</span>
</span>
</label>
</div>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
</CardContent>
</Card>
{/* --- Gesundheit ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Gesundheit und Pflege</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diagnosen, Versichertennummer und der ermittelte Wille sind besondere Daten nach
Art. 9 DSGVO. Sie werden zusätzlich verschlüsselt gespeichert und sind ohne den
Schlüssel aus der Serverumgebung auch aus einem Backup nicht lesbar.
</AlertDescription>
</Alert>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="krankenkasse" beschriftung="Krankenkasse" fehler={zustand.felder?.krankenkasse}>
<Input name="krankenkasse" defaultValue={wert('krankenkasse')} />
</Feld>
<Feld
id="krankenversichertennummer"
beschriftung="Versichertennummer"
fehler={zustand.felder?.krankenversichertennummer}
>
<Input name="krankenversichertennummer" defaultValue={wert('krankenversichertennummer')} />
</Feld>
<Feld id="pflegekasse" beschriftung="Pflegekasse" fehler={zustand.felder?.pflegekasse}>
<Input name="pflegekasse" defaultValue={wert('pflegekasse')} />
</Feld>
<Feld id="pflegegrad" beschriftung="Pflegegrad" erklaerung="1 bis 5" fehler={zustand.felder?.pflegegrad}>
<Input name="pflegegrad" type="number" min={1} max={5} defaultValue={wert('pflegegrad')} />
</Feld>
<Feld id="pflegegradSeit" beschriftung="Pflegegrad seit" fehler={zustand.felder?.pflegegradSeit}>
<Input name="pflegegradSeit" type="date" defaultValue={alsDatumswert(wert('pflegegradSeit'))} />
</Feld>
<Feld
id="pflegegradBescheidVom"
beschriftung="Bescheid vom"
fehler={zustand.felder?.pflegegradBescheidVom}
>
<Input
name="pflegegradBescheidVom"
type="date"
defaultValue={alsDatumswert(wert('pflegegradBescheidVom'))}
/>
</Feld>
<Feld
id="schwerbehinderungGdB"
beschriftung="Grad der Behinderung"
erklaerung="20 bis 100"
fehler={zustand.felder?.schwerbehinderungGdB}
>
<Input
name="schwerbehinderungGdB"
type="number"
min={20}
max={100}
step={10}
defaultValue={wert('schwerbehinderungGdB')}
/>
</Feld>
<Feld
id="schwerbehinderungMerkzeichen"
beschriftung="Merkzeichen"
erklaerung="z. B. G, aG, B, H, Bl, Gl"
fehler={zustand.felder?.schwerbehinderungMerkzeichen}
>
<Input
name="schwerbehinderungMerkzeichen"
defaultValue={wert('schwerbehinderungMerkzeichen')}
/>
</Feld>
</div>
<Feld
id="diagnosen"
beschriftung="Diagnosen"
erklaerung="Eine Diagnose je Zeile, gern mit Datum der Feststellung."
fehler={zustand.felder?.diagnosen}
>
<Textarea name="diagnosen" defaultValue={wert('diagnosen')} rows={4} />
</Feld>
<Feld
id="ermittelterWille"
beschriftung="Ermittelter Wille der betreuten Person"
erklaerung="Was möchte sie selbst? Seit der Reform 2023 ist das die Leitschnur deines Handelns — auch dann, wenn du es anders entscheiden würdest."
fehler={zustand.felder?.ermittelterWille}
>
<Textarea name="ermittelterWille" defaultValue={wert('ermittelterWille')} rows={4} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
<div className="space-y-2">
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="patientenverfuegungVorhanden"
defaultChecked={werte.patientenverfuegungVorhanden}
/>
<span className="text-sm font-medium">Patientenverfügung liegt vor</span>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="vorsorgevollmachtVorhanden"
defaultChecked={werte.vorsorgevollmachtVorhanden}
/>
<span className="text-sm font-medium">Vorsorgevollmacht liegt vor</span>
</label>
</div>
</CardContent>
</Card>
{/* --- Behörden -------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Behörden und Kennzeichen</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld
id="aktenzeichenBezirk"
beschriftung="Aktenzeichen Bezirk"
erklaerung="Hilfe zur Pflege nach dem 7. Kapitel SGB XII."
fehler={zustand.felder?.aktenzeichenBezirk}
>
<Input name="aktenzeichenBezirk" defaultValue={wert('aktenzeichenBezirk')} />
</Feld>
<Feld
id="aktenzeichenSozialamt"
beschriftung="Aktenzeichen Sozialamt"
fehler={zustand.felder?.aktenzeichenSozialamt}
>
<Input name="aktenzeichenSozialamt" defaultValue={wert('aktenzeichenSozialamt')} />
</Feld>
<Feld
id="rentenversicherungsnummer"
beschriftung="Rentenversicherungsnummer"
fehler={zustand.felder?.rentenversicherungsnummer}
>
<Input name="rentenversicherungsnummer" defaultValue={wert('rentenversicherungsnummer')} />
</Feld>
<Feld id="steuerId" beschriftung="Steuer-Identifikationsnummer" fehler={zustand.felder?.steuerId}>
<Input name="steuerId" inputMode="numeric" defaultValue={wert('steuerId')} />
</Feld>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Notizen</CardTitle>
</CardHeader>
<CardContent>
<Feld id="notizen" beschriftung="Freitext" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={wert('notizen')} rows={5} />
</Feld>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button asChild variant="outline" size="lg">
<Link href="/stammdaten">Abbrechen</Link>
</Button>
<Speichern />
</div>
</form>
)
}
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { SeitenKopf } from '@/components/seiten-kopf'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { oeffnen } from '@/lib/crypto/feld'
import { StammdatenFormular, type StammdatenWerte } from './formular'
export const metadata: Metadata = { title: 'Stammdaten bearbeiten' }
export const dynamic = 'force-dynamic'
/** Date → "JJJJ-MM-TT" für das Datumsfeld im Browser. */
function alsText(datum: Date | null): string | null {
return datum ? datum.toISOString().slice(0, 10) : null
}
export default async function StammdatenBearbeitenSeite() {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
// Schreibrecht wird hier geprüft, nicht erst beim Absenden.
const { betreute } = await erfordereZugang(aktive.id, 'STAMMDATEN', 'schreiben')
const offen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
const werte: StammdatenWerte = {
id: betreute.id,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsname: betreute.geburtsname,
geburtsdatum: alsText(betreute.geburtsdatum) ?? '',
geburtsort: betreute.geburtsort,
anrede: betreute.anrede,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimTelefon: betreute.heimTelefon,
heimEinzugAm: alsText(betreute.heimEinzugAm),
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
krankenkasse: betreute.krankenkasse,
krankenversichertennummer: offen.krankenversichertennummer,
pflegekasse: betreute.pflegekasse,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: alsText(betreute.pflegegradSeit),
pflegegradBescheidVom: alsText(betreute.pflegegradBescheidVom),
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: offen.diagnosen,
ermittelterWille: offen.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: alsText(betreute.bestellungAm),
bestellungBis: alsText(betreute.bestellungBis),
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
befreiterBetreuer: betreute.befreiterBetreuer,
ehrenamtlich: betreute.ehrenamtlich,
aktenzeichenBezirk: betreute.aktenzeichenBezirk,
aktenzeichenSozialamt: betreute.aktenzeichenSozialamt,
rentenversicherungsnummer: offen.rentenversicherungsnummer,
steuerId: offen.steuerId,
notizen: offen.notizen,
}
return (
<div className="space-y-6">
<SeitenKopf
titel="Stammdaten bearbeiten"
beschreibung="Was hier steht, taucht in jedem Bericht und in jedem Anschreiben wieder auf. Es lohnt sich, es einmal in Ruhe vollständig einzutragen."
pfad={[
{ titel: 'Stammdaten', href: '/stammdaten' },
{ titel: 'Bearbeiten' },
]}
/>
<StammdatenFormular werte={werte} />
</div>
)
}
+302
View File
@@ -0,0 +1,302 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { CalendarClock, Pencil, ScrollText, Stethoscope } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { oeffnen } from '@/lib/crypto/feld'
import { formatDatum, tageBis } from '@/lib/utils'
import { AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { betreuungsjahr } from '@/lib/stammdaten/betreuungsjahr'
export const metadata: Metadata = { title: 'Stammdaten' }
export const dynamic = 'force-dynamic'
/** Zeigt eine Angabe an, oder einen Gedankenstrich, wenn sie fehlt. */
function Angabe({
bezeichnung,
wert,
hinweis,
}: {
bezeichnung: string
wert: React.ReactNode
hinweis?: string
}) {
const leer = wert === null || wert === undefined || wert === '' || wert === '–'
return (
<div className="py-2.5">
<dt className="text-sm text-muted-foreground">{bezeichnung}</dt>
<dd className={leer ? 'text-muted-foreground' : 'font-medium'}>{leer ? '–' : wert}</dd>
{hinweis && <p className="mt-0.5 text-xs text-muted-foreground">{hinweis}</p>}
</div>
)
}
export default async function StammdatenSeite() {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { betreute, darfSchreiben } = await erfordereZugang(aktive.id, 'STAMMDATEN')
const offen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
const jahr = betreuungsjahr(betreute.bestellungAm)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${betreute.vorname} ${betreute.nachname}`}
beschreibung={
<>
Geboren am {formatDatum(betreute.geburtsdatum)}
{betreute.geburtsname && ` · geborene ${betreute.geburtsname}`}
</>
}
aktionen={
darfSchreiben('STAMMDATEN') && (
<Button asChild>
<Link href="/stammdaten/bearbeiten">
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
)
}
/>
{/* Das Betreuungsjahr steuert fast alle Fristen - deshalb ganz oben. */}
{jahr && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="size-5" aria-hidden="true" />
Laufendes Betreuungsjahr
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p>
<span className="font-medium">
{formatDatum(jahr.beginn)} bis {formatDatum(jahr.ende)}
</span>
<span className="ml-2 text-muted-foreground">
(noch {Math.max(0, tageBis(jahr.ende))} Tage)
</span>
</p>
<p className="text-sm text-muted-foreground">
Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Der
Jahresbericht wird zu seinem Ende fällig; die Aufwandspauschale muss binnen sechs
Monaten danach beantragt werden — bis zum{' '}
<strong className="text-foreground">{formatDatum(jahr.pauschaleBis)}</strong>, sonst
verfällt der Anspruch.
</p>
<Rechtshinweis rechtsgrundlage="§§ 1863, 1878 BGB" kurz />
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-base">Person und Aufenthalt</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Anrede" wert={betreute.anrede} />
<Angabe bezeichnung="Geburtsort" wert={betreute.geburtsort} />
<Angabe bezeichnung="Einrichtung" wert={betreute.heimName} />
<Angabe
bezeichnung="Anschrift"
wert={
betreute.heimStrasse
? `${betreute.heimStrasse}, ${betreute.heimPlz ?? ''} ${betreute.heimOrt ?? ''}`.trim()
: null
}
/>
<Angabe bezeichnung="Zimmer" wert={betreute.heimZimmer} />
<Angabe bezeichnung="Telefon Einrichtung" wert={betreute.heimTelefon} />
<Angabe bezeichnung="Eingezogen am" wert={formatDatum(betreute.heimEinzugAm)} />
<Angabe
bezeichnung="Frühere Anschrift"
wert={
betreute.fruehereStrasse
? `${betreute.fruehereStrasse}, ${betreute.frueherePlz ?? ''} ${betreute.fruehererOrt ?? ''}`.trim()
: null
}
hinweis="Bleibt für Behörden und für die Immobilie relevant."
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="flex items-center gap-2 text-base">
<ScrollText className="size-5" aria-hidden="true" />
Betreuungsgericht
</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Gericht" wert={betreute.betreuungsgericht} />
<Angabe bezeichnung="Aktenzeichen" wert={betreute.aktenzeichenGericht} />
<Angabe bezeichnung="Bestellt am" wert={formatDatum(betreute.bestellungAm)} />
<Angabe bezeichnung="Befristet bis" wert={formatDatum(betreute.bestellungBis)} />
<div className="py-2.5">
<dt className="text-sm text-muted-foreground">Aufgabenkreise</dt>
<dd className="mt-1.5 flex flex-wrap gap-1.5">
{betreute.aufgabenkreise.length === 0 ? (
<span className="text-muted-foreground">–</span>
) : (
betreute.aufgabenkreise.map((kreis) => (
<Badge key={kreis} variant="secondary">
{AUFGABENKREIS_BESCHRIFTUNG[kreis]}
</Badge>
))
)}
</dd>
{betreute.aufgabenkreiseFreitext && (
<p className="mt-1.5 text-sm text-muted-foreground">
{betreute.aufgabenkreiseFreitext}
</p>
)}
</div>
<Angabe
bezeichnung="Art der Betreuung"
wert={
<span className="flex flex-wrap gap-1.5">
<Badge variant={betreute.ehrenamtlich ? 'gruen' : 'secondary'}>
{betreute.ehrenamtlich ? 'ehrenamtlich' : 'beruflich'}
</Badge>
<Badge variant={betreute.befreiterBetreuer ? 'gruen' : 'secondary'}>
{betreute.befreiterBetreuer ? 'befreit (§ 1859 BGB)' : 'nicht befreit'}
</Badge>
</span>
}
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="flex items-center gap-2 text-base">
<Stethoscope className="size-5" aria-hidden="true" />
Gesundheit und Pflege
</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Krankenkasse" wert={betreute.krankenkasse} />
<Angabe
bezeichnung="Versichertennummer"
wert={offen.krankenversichertennummer}
hinweis="Verschlüsselt gespeichert."
/>
<Angabe bezeichnung="Pflegekasse" wert={betreute.pflegekasse} />
<Angabe
bezeichnung="Pflegegrad"
wert={
betreute.pflegegrad ? (
<>
Pflegegrad {betreute.pflegegrad}
{betreute.pflegegradSeit && (
<span className="ml-2 font-normal text-muted-foreground">
seit {formatDatum(betreute.pflegegradSeit)}
</span>
)}
</>
) : null
}
/>
<Angabe
bezeichnung="Schwerbehinderung"
wert={
betreute.schwerbehinderungGdB
? `GdB ${betreute.schwerbehinderungGdB}${
betreute.schwerbehinderungMerkzeichen
? ` · ${betreute.schwerbehinderungMerkzeichen}`
: ''
}`
: null
}
/>
<Angabe
bezeichnung="Diagnosen"
wert={
offen.diagnosen ? (
<span className="whitespace-pre-line font-normal">{offen.diagnosen}</span>
) : null
}
hinweis="Verschlüsselt gespeichert (Art. 9 DSGVO)."
/>
<Angabe
bezeichnung="Vorsorge"
wert={
<span className="flex flex-wrap gap-1.5">
<Badge variant={betreute.patientenverfuegungVorhanden ? 'gruen' : 'outline'}>
Patientenverfügung {betreute.patientenverfuegungVorhanden ? 'liegt vor' : 'fehlt'}
</Badge>
<Badge variant={betreute.vorsorgevollmachtVorhanden ? 'gruen' : 'outline'}>
Vorsorgevollmacht {betreute.vorsorgevollmachtVorhanden ? 'liegt vor' : 'fehlt'}
</Badge>
</span>
}
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-base">Behörden und Kennzeichen</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe
bezeichnung="Aktenzeichen Bezirk"
wert={betreute.aktenzeichenBezirk}
hinweis="Hilfe zur Pflege nach dem 7. Kapitel SGB XII."
/>
<Angabe bezeichnung="Aktenzeichen Sozialamt" wert={betreute.aktenzeichenSozialamt} />
<Angabe
bezeichnung="Rentenversicherungsnummer"
wert={offen.rentenversicherungsnummer}
hinweis="Verschlüsselt gespeichert."
/>
<Angabe
bezeichnung="Steuer-Identifikationsnummer"
wert={offen.steuerId}
hinweis="Verschlüsselt gespeichert."
/>
</dl>
</CardContent>
</Card>
</div>
{offen.ermittelterWille && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Ermittelter Wille</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="whitespace-pre-line">{offen.ermittelterWille}</p>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
</CardContent>
</Card>
)}
{offen.notizen && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Notizen</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line">{offen.notizen}</p>
</CardContent>
</Card>
)}
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
import Link from 'next/link'
import { ArrowLeft, ListChecks, Scale } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns'
/**
* Neuen Vorgang anlegen - erst die Vorlage wählen, dann die Angaben.
*
* Zwei Schritte statt einem: Die Vorlage entscheidet über die
* Aufgabenliste und darüber, ob ein Genehmigungsverfahren angelegt wird.
* Das soll man bewusst wählen, nicht nebenbei in einem Auswahlfeld.
*/
export default async function NeuerVorgangSeite({
searchParams,
}: {
searchParams: Promise<{ vorlage?: string; leer?: string }>
}) {
const benutzer = await erfordereBenutzer('/vorgaenge/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
const auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1'
// Schritt 1: Kachelliste
if (!vorlage && !leer) {
return (
<div className="space-y-6">
<SeitenKopf
titel="Neuer Vorgang"
beschreibung="Wähle eine Vorlage - sie bringt die Aufgabenliste in der fachlich richtigen Reihenfolge mit. Oder fang leer an."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: 'Neu' },
]}
aktionen={
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
}
/>
<VorlagenKacheln />
</div>
)
}
// Schritt 2: Angaben
const heute = new Date()
return (
<div className="space-y-6">
<SeitenKopf
titel={vorlage ? vorlage.titel : 'Leerer Vorgang'}
beschreibung={
vorlage
? vorlage.beschreibung
: 'Ohne vorbefüllte Aufgaben. Aufgaben lassen sich danach jederzeit ergänzen.'
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: 'Neu', href: '/vorgaenge/neu' },
{ titel: vorlage ? vorlage.titel : 'Leer' },
]}
aktionen={
<Button variant="ghost" asChild>
<Link href="/vorgaenge/neu">
<ArrowLeft aria-hidden="true" />
Andere Vorlage
</Link>
</Button>
}
/>
{vorlage?.genehmigungspflichtig && (
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>
Dieser Vorgang ist genehmigungspflichtig
{vorlage.rechtsgrundlage ? ` (${vorlage.rechtsgrundlage})` : ''}
</AlertTitle>
<AlertDescription>
{GENEHMIGUNG_WARNUNG} Die Genehmigungskette wird beim Anlegen gleich mit erzeugt.
</AlertDescription>
</Alert>
)}
{vorlage && (
<section className="space-y-3 rounded-lg border p-4" aria-labelledby="vorschau">
<h2 id="vorschau" className="flex items-center gap-2 text-base font-semibold">
<ListChecks className="size-4" aria-hidden="true" />
Diese Aufgaben werden angelegt
</h2>
<ol className="space-y-2">
{vorlage.aufgaben.map((aufgabe, index) => (
<li key={aufgabe.titel} className="flex gap-3 text-sm">
<span className="tabular-nums text-muted-foreground">{index + 1}.</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{aufgabe.titel}</span>
{aufgabe.tageBisFaellig !== undefined && (
<span className="text-muted-foreground">
{' '}
· fällig {formatDatum(addDays(heute, aufgabe.tageBisFaellig))}
</span>
)}
</span>
</li>
))}
</ol>
{vorlage.fristen && vorlage.fristen.length > 0 && (
<div className="space-y-2 border-t pt-3">
<h3 className="text-sm font-semibold">Zusätzliche Fristen</h3>
<ul className="space-y-1">
{vorlage.fristen.map((frist) => (
<li key={frist.titel} className="text-sm">
{frist.titel}{' '}
<span className="text-muted-foreground">
· {formatDatum(addDays(heute, frist.tageBisFaellig))}
</span>
{frist.rechtsgrundlage && (
<Badge variant="outline" className="ml-2">
{frist.rechtsgrundlage}
</Badge>
)}
</li>
))}
</ul>
</div>
)}
{vorlage.rechtsgrundlage && <Rechtshinweis rechtsgrundlage={vorlage.rechtsgrundlage} />}
</section>
)}
<VorgangsFormular
modus="anlegen"
betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge"
werte={{
titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES',
status: 'OFFEN',
prioritaet: 2,
beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null,
aktenzeichen: null,
verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null,
letzterKontaktAm: null,
genehmigungspflichtig: vorlage?.genehmigungspflichtig ?? false,
}}
/>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import Link from 'next/link'
import { Plus } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { VorgaengeAnsicht, type VorgangsZeile } from '@/components/vorgaenge/vorgaenge-ansicht'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { KATEGORIE_BESCHRIFTUNG, VORGANG_KATEGORIEN } from '@/lib/validierung/vorgaenge'
/**
* Übersicht aller Vorgänge.
*
* Voreingestellt sind die offenen Vorgänge - Erledigtes soll den Blick auf
* das, was liegt, nicht verstellen. Über den Filter kommt man an alles.
*/
export default async function VorgaengeSeite({
searchParams,
}: {
searchParams: Promise<{ kategorie?: string; erledigte?: string }>
}) {
const benutzer = await erfordereBenutzer('/vorgaenge')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const filter = await searchParams
const kategorie = (VORGANG_KATEGORIEN as readonly string[]).includes(filter.kategorie ?? '')
? (filter.kategorie as VorgangKategorie)
: undefined
const mitErledigten = filter.erledigte === '1'
const gefunden = await db.vorgang.findMany({
where: {
betreuteId: aktive.id,
...nichtGeloescht,
...(kategorie ? { kategorie } : {}),
...(mitErledigten ? {} : { status: { not: 'ERLEDIGT' as VorgangStatus } }),
},
include: { aufgaben: { select: { erledigt: true } } },
orderBy: [{ prioritaet: 'asc' }, { faelligAm: 'asc' }, { aktualisiertAm: 'desc' }],
})
const vorgaenge: VorgangsZeile[] = gefunden.map((vorgang) => ({
id: vorgang.id,
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
aufgabenGesamt: vorgang.aufgaben.length,
aufgabenOffen: vorgang.aufgaben.filter((a) => !a.erledigt).length,
}))
return (
<div className="space-y-6">
<SeitenKopf
titel="Vorgänge"
beschreibung="Jede Sache, die läuft, hat hier ihre Akte: Aufgaben, Fristen, Schriftverkehr und - wo nötig - das Genehmigungsverfahren."
pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Vorgänge' }]}
aktionen={
darfSchreiben('VORGAENGE') ? (
<Button asChild>
<Link href="/vorgaenge/neu">
<Plus aria-hidden="true" />
Neuer Vorgang
</Link>
</Button>
) : undefined
}
/>
{/* Filter ohne JavaScript: ein normales Formular mit GET. */}
<form
method="get"
className="flex flex-wrap items-end gap-3 rounded-lg border p-4"
aria-label="Vorgänge filtern"
>
<div className="min-w-48 flex-1">
<label htmlFor="kategorie" className="mb-1.5 block text-sm font-medium">
Kategorie
</label>
<Select id="kategorie" name="kategorie" defaultValue={kategorie ?? ''}>
<option value="">Alle Kategorien</option>
{VORGANG_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</div>
<div className="flex items-center gap-2">
<input
id="erledigte"
name="erledigte"
type="checkbox"
value="1"
defaultChecked={mitErledigten}
className="size-5 rounded border-input accent-primary"
/>
<label htmlFor="erledigte" className="text-sm font-medium">
Erledigte mitzeigen
</label>
</div>
<Button type="submit" variant="outline">
Filtern
</Button>
</form>
{vorgaenge.length === 0 ? (
<Leerzustand
titel={kategorie ? 'In dieser Kategorie liegt nichts.' : 'Noch kein Vorgang angelegt.'}
beschreibung="Fang mit dem an, was gerade drückt - für die häufigsten Fälle gibt es Vorlagen mit fertiger Aufgabenliste."
aktion={
darfSchreiben('VORGAENGE') ? (
<Button asChild>
<Link href="/vorgaenge/neu">Vorgang aus Vorlage anlegen</Link>
</Button>
) : undefined
}
/>
) : (
<VorgaengeAnsicht vorgaenge={vorgaenge} darfSchreiben={darfSchreiben('VORGAENGE')} />
)}
</div>
)
}
@@ -0,0 +1,150 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { betreuteAnlegen, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Betreuung anlegen und loslegen'}
</Button>
)
}
/** Nur die vier häufigsten Aufgabenkreise werden vorangestellt. */
const HAEUFIG = ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE', 'AUFENTHALTSBESTIMMUNG', 'WOHNUNGSANGELEGENHEITEN']
export function BetreuungFormular() {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAnlegen, {})
return (
<form action={aktion} className="space-y-5" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={zustand.werte?.vorname} required autoFocus />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={zustand.werte?.nachname} required />
</Feld>
</div>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={zustand.werte?.geburtsdatum}
required
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="betreuungsgericht"
beschriftung="Betreuungsgericht"
fehler={zustand.felder?.betreuungsgericht}
>
<Input
name="betreuungsgericht"
placeholder="Amtsgericht …"
defaultValue={zustand.werte?.betreuungsgericht}
/>
</Feld>
<Feld
id="aktenzeichenGericht"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichenGericht}
>
<Input
name="aktenzeichenGericht"
placeholder="XVII 123/25"
defaultValue={zustand.werte?.aktenzeichenGericht}
/>
</Feld>
</div>
<Feld
id="bestellungAm"
beschriftung="Datum der Bestellung zum Betreuer"
erklaerung="Steht in deiner Bestellungsurkunde. Daraus ergibt sich das Betreuungsjahr — und damit, wann Anfangsbericht, Jahresbericht und die Aufwandspauschale fällig sind."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={zustand.werte?.bestellungAm} />
</Feld>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<p className="text-sm text-muted-foreground">
Die in der Bestellungsurkunde genannten Bereiche.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{[...HAEUFIG, ...AUFGABENKREISE.filter((a) => !HAEUFIG.includes(a))].map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={
kreis === 'VERMOEGENSSORGE' || kreis === 'GESUNDHEITSSORGE'
}
/>
<span className="text-sm">
{AUFGABENKREIS_BESCHRIFTUNG[kreis as keyof typeof AUFGABENKREIS_BESCHRIFTUNG]}
</span>
</label>
))}
</div>
</fieldset>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer (§ 1859 BGB)</span>
<span className="mt-0.5 block text-muted-foreground">
Trifft auf Angehörige in der Regel zu. Statt der vollständigen Rechnungslegung genügt
dann eine Vermögensübersicht.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Dann steht dir die jährliche Aufwandspauschale nach § 1878 BGB zu — die Anwendung
erinnert dich rechtzeitig, weil der Anspruch sonst verfällt.
</span>
</span>
</label>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
<Absenden />
</form>
)
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { KeyRound, UserPlus } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { aktuellerBenutzer, meineBetreuten } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { ZugangFormular } from './zugang-formular'
import { BetreuungFormular } from './betreuung-formular'
export const metadata: Metadata = { title: 'Ersteinrichtung' }
export const dynamic = 'force-dynamic'
/**
* Ersteinrichtung in drei Schritten.
*
* Diese Seite liegt bewusst ausserhalb des Anwendungsrahmens: der Rahmen
* setzt einen Benutzer, einen zweiten Faktor UND eine betreute Person
* voraus. Beim allerersten Start gibt es nichts davon.
*/
export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) {
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="size-5" aria-hidden="true" />
Schritt 1 von 3: Dein Zugang
</CardTitle>
<CardDescription>
Dieser erste Zugang verwaltet auch die Anwendung. Danach richtest du den zweiten
Faktor ein und legst die betreute Person an.
</CardDescription>
</CardHeader>
<CardContent>
<ZugangFormular />
</CardContent>
</Card>
<Alert variant="hinweis">
<KeyRound aria-hidden="true" />
<AlertTitle>Zum Passwort</AlertTitle>
<AlertDescription>
Nimm vier zufällige Wörter statt eines kurzen Kunstworts —{' '}
<em>Kastanie-Waschbär-Turm-Grün</em> ist leichter zu merken und deutlich schwerer zu
knacken. Ein Passwortmanager ist noch besser.
</AlertDescription>
</Alert>
</>
)
}
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
// --- Schritt 2: Zweiter Faktor fehlt noch --------------------------------
if (!benutzer.totpAktiviertAm) redirect('/zwei-faktor/einrichten')
// --- Schritt 3: Noch keine betreute Person ------------------------------
const betreute = await meineBetreuten(benutzer)
if (betreute.length > 0) redirect('/heute')
return (
<Card>
<CardHeader>
<CardTitle>Schritt 3 von 3: Die betreute Person</CardTitle>
<CardDescription>
Nur das Nötigste — alles Weitere trägst du später in Ruhe unter Stammdaten nach. Das{' '}
<strong>Bestellungsdatum</strong> ist das wichtigste Feld: daraus berechnet die Anwendung
das Betreuungsjahr und damit deine gesetzlichen Fristen.
</CardDescription>
</CardHeader>
<CardContent>
<BetreuungFormular />
</CardContent>
</Card>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { erstenZugangAnlegen, type ZugangZustand } from '@/server/actions/ersteinrichtung'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Zugang anlegen'}
</Button>
)
}
export function ZugangFormular() {
const [zustand, aktion] = useActionState<ZugangZustand, FormData>(erstenZugangAnlegen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="name" beschriftung="Dein Name" fehler={zustand.felder?.name} pflicht>
<Input
name="name"
autoComplete="name"
defaultValue={zustand.werte?.name}
placeholder="Vorname Nachname"
required
autoFocus
/>
</Feld>
<Feld
id="email"
beschriftung="E-Mail-Adresse"
erklaerung="Damit meldest du dich an. Sie wird nicht nach außen versendet."
fehler={zustand.felder?.email}
pflicht
>
<Input
name="email"
type="email"
autoComplete="username"
inputMode="email"
autoCapitalize="none"
spellCheck={false}
defaultValue={zustand.werte?.email}
required
/>
</Feld>
<Feld
id="passwort"
beschriftung="Passwort"
erklaerung="Mindestens 12 Zeichen."
fehler={zustand.felder?.passwort}
pflicht
>
<Input name="passwort" type="password" autoComplete="new-password" required />
</Feld>
<Feld
id="passwortWiederholung"
beschriftung="Passwort wiederholen"
fehler={zustand.felder?.passwortWiederholung}
pflicht
>
<Input name="passwortWiederholung" type="password" autoComplete="new-password" required />
</Feld>
<Absenden />
</form>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import { AlertCircle, Check, Copy, Loader2, ShieldCheck } from 'lucide-react'
import {
zweitenFaktorBestaetigen,
type BestaetigungsZustand,
} from '@/server/actions/zwei-faktor'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird geprüft …' : 'Code bestätigen und aktivieren'}
</Button>
)
}
/** Base32 in Vierergruppen — so tippt es sich von Hand deutlich sicherer. */
function gruppiert(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim()
}
export function EinrichtungsFormular({ qrCode, secret }: { qrCode: string; secret: string }) {
const [zustand, aktion] = useActionState<BestaetigungsZustand, FormData>(
zweitenFaktorBestaetigen,
{},
)
const [kopiert, setKopiert] = useState(false)
const router = useRouter()
// --- Nach erfolgreicher Aktivierung: die Codes, genau einmal ------------
if (zustand.wiederherstellungscodes) {
return (
<div className="space-y-5">
<Alert variant="erfolg">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Zweiter Faktor ist aktiv</AlertTitle>
<AlertDescription>
Ab jetzt brauchst du bei jeder Anmeldung den Code aus deiner App.
</AlertDescription>
</Alert>
<div className="space-y-2">
<h3 className="font-semibold">Deine Wiederherstellungscodes</h3>
<p className="text-sm text-muted-foreground">
Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code
funktioniert genau einmal. <strong>Sie werden dir nie wieder angezeigt.</strong> Druck
sie aus und leg sie zu deinen Betreuungsunterlagen — nicht auf dasselbe Gerät.
</p>
</div>
<ul className="grid grid-cols-2 gap-2 rounded-md border bg-muted/50 p-4 font-mono text-sm">
{zustand.wiederherstellungscodes.map((code) => (
<li key={code} className="tracking-wide">
{code}
</li>
))}
</ul>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
void navigator.clipboard
.writeText(zustand.wiederherstellungscodes!.join('\n'))
.then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Codes kopieren'}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => window.print()}>
Drucken
</Button>
</div>
<Button
type="button"
size="lg"
className="w-full"
onClick={() => router.push('/ersteinrichtung')}
>
Ich habe die Codes gesichert — weiter
</Button>
</div>
)
}
// --- Einrichtung -------------------------------------------------------
return (
<div className="space-y-5">
<ol className="space-y-2 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">1.</span> Installiere eine
Authenticator-App — etwa <strong>Aegis</strong> (Android) oder{' '}
<strong>2FAS</strong> (iOS). Beide sind quelloffen und kommen ohne Konto aus.
</li>
<li>
<span className="font-medium text-foreground">2.</span> Scanne den Code oder tippe das
Geheimnis ab.
</li>
<li>
<span className="font-medium text-foreground">3.</span> Gib die sechs Ziffern ein, die
die App anzeigt.
</li>
</ol>
{qrCode && (
<div className="flex justify-center rounded-lg border bg-white p-4">
{/* Der QR-Code entsteht serverseitig und ist als Data-URI eingebettet -
kein externer Dienst, kein CDN. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="QR-Code zum Einrichten der Authenticator-App" width={240} height={240} />
</div>
)}
<div className="space-y-1.5">
<p className="text-sm font-medium">Geheimnis zum Abtippen</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2.5 font-mono text-sm">
{gruppiert(secret)}
</code>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Geheimnis kopieren"
onClick={() => {
void navigator.clipboard.writeText(secret).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</Button>
</div>
</div>
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="code" beschriftung="Sechsstelliger Code aus der App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={7}
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
autoFocus
/>
</Feld>
<Absenden />
</form>
</div>
)
}
@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { zweitenFaktorVorbereiten } from '@/server/actions/zwei-faktor'
import { EinrichtungsFormular } from './formular'
export const metadata: Metadata = { title: 'Zweiten Faktor einrichten' }
export const dynamic = 'force-dynamic'
export default async function ZweiFaktorEinrichtenSeite() {
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
if (benutzer.totpAktiviertAm) redirect('/heute')
const daten = await zweitenFaktorVorbereiten()
return (
<Card>
<CardHeader>
<CardTitle>Schritt 2 von 3: Zweiter Faktor</CardTitle>
<CardDescription>
Hier liegen Gesundheits- und Vermögensdaten einer Person, die nicht selbst entscheiden
kann. Ein Passwort allein reicht dafür nicht — der zweite Faktor ist Pflicht und lässt
sich nicht abschalten.
</CardDescription>
</CardHeader>
<CardContent>
<EinrichtungsFormular qrCode={daten.qrCode} secret={daten.secret} />
</CardContent>
</Card>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
/**
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
*
* Bewusst ohne Anmeldung, aber auch ohne jede Auskunft ueber Inhalte:
* geprueft wird nur, ob die Datenbank antwortet. Version und Datenbestand
* gehen niemanden etwas an, der die Adresse errät.
*/
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
await db.$queryRaw`SELECT 1`
return NextResponse.json({ status: 'bereit' }, { headers: { 'Cache-Control': 'no-store' } })
} catch {
return NextResponse.json(
{ status: 'datenbank nicht erreichbar' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
}
+140
View File
@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server'
import { darf } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db'
import { env } from '@/lib/env'
import { heuteAlsTag, plusJahre, plusTage } from '@/lib/fristen/datum'
import { icsErzeugen, signaturPruefen, type IcsFrist } from '@/lib/fristen/ics'
/**
* Abonnierbarer Fristenkalender.
*
* Diese Route ist die einzige Stelle der Anwendung, die Fachdaten ohne
* Anmeldung herausgibt - ein Kalenderprogramm kann sich nicht anmelden und
* keinen zweiten Faktor eingeben. Der Ausgleich dafuer:
*
* 1. Der Zugang haengt an einer HMAC-signierten Adresse. Ohne AUTH_SECRET
* laesst sie sich nicht erraten oder faelschen.
* 2. Es wird trotzdem geprueft, ob der in der Adresse genannte Benutzer
* heute noch aktiv ist und noch Lesezugriff auf das Modul FRISTEN hat.
* Wird ein Mitleser entzogen, ist auch sein Kalender sofort leer.
* 3. Ausgeliefert werden nur Titel, Stichtag und Rechtsgrundlage. Kein
* Name der betreuten Person, keine Diagnosen, keine Betraege.
* 4. Kein Caching durch Zwischenstationen.
*/
export const dynamic = 'force-dynamic'
/** Ein Jahr zurueck, drei nach vorn - mehr braucht kein Kalender. */
const RUECKBLICK_TAGE = 365
const VORSCHAU_JAHRE = 3
export async function GET(
_anfrage: Request,
kontext: { params: Promise<{ signatur: string }> },
): Promise<Response> {
const { signatur } = await kontext.params
const gepruefte = signaturPruefen(signatur)
if (!gepruefte) return abgelehnt()
const { betreuteId, userId } = gepruefte
const [benutzer, betreute, zugang] = await Promise.all([
db.user.findUnique({
where: { id: userId },
select: { id: true, aktiv: true, geloeschtAm: true, rolle: true },
}),
db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { id: true, vorname: true, nachname: true },
}),
db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
}),
])
if (!benutzer || !benutzer.aktiv || benutzer.geloeschtAm || !betreute) return abgelehnt()
const rechte = zugang
? { rolle: zugang.rolle, freigegebeneModule: zugang.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
: null
if (!darf(rechte, 'FRISTEN', 'lesen')) return abgelehnt()
const heute = heuteAlsTag()
const datensaetze = await db.frist.findMany({
where: {
betreuteId,
...nichtGeloescht,
status: { in: ['OFFEN', 'ERLEDIGT'] },
datum: { gte: plusTage(heute, -RUECKBLICK_TAGE), lte: plusJahre(heute, VORSCHAU_JAHRE) },
},
orderBy: { datum: 'asc' },
select: {
id: true,
titel: true,
beschreibung: true,
datum: true,
typ: true,
status: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorwarnungTage: true,
aktualisiertAm: true,
},
})
const fristen: IcsFrist[] = datensaetze.map((f) => ({
id: f.id,
// Erledigtes bleibt im Kalender stehen, damit die Historie stimmt - aber
// als solches erkennbar und ohne Wecker.
titel: f.status === 'ERLEDIGT' ? `✓ ${f.titel}` : f.titel,
beschreibung: f.beschreibung,
datum: f.datum,
rechtsgrundlage: f.rechtsgrundlage,
typ: f.typ,
ausschlussfrist: f.ausschlussfrist && f.status === 'OFFEN',
vorwarnungTage: f.status === 'OFFEN' ? f.vorwarnungTage : [],
aktualisiertAm: f.aktualisiertAm,
}))
// Nur die Initialen der betreuten Person: Der Kalendername steht auf dem
// Sperrbildschirm des Telefons und in der Kalenderliste des Anbieters.
const initialen = `${betreute.vorname.charAt(0)}${betreute.nachname.charAt(0)}`.toUpperCase()
const ics = icsErzeugen({
kalendername: `Betreuung ${initialen} — Fristen`,
fristen,
basisUrl: env.basisUrl,
})
return new NextResponse(ics, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'inline; filename="fristen.ics"',
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
'X-Robots-Tag': 'noindex, nofollow',
},
})
}
/**
* Immer dieselbe Antwort, egal woran es lag - ob die Signatur falsch war,
* der Benutzer gesperrt oder die betreute Person geloescht, geht niemanden
* etwas an, der die Adresse durchprobiert.
*/
function abgelehnt(): Response {
return new NextResponse('Dieser Kalender ist nicht (mehr) verfügbar.', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
})
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation'
/**
* Einstiegspunkt. Wer angemeldet ist, landet auf dem Tagesdashboard;
* alles Weitere entscheidet der Anwendungsrahmen unter (app).
*/
export default function Startseite() {
redirect('/heute')
}