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